🧩 Design System8. Pipeline & Distribution05 — Storybook과 문서: Storybook 9 + MDX + Chromatic 시각 회귀

05 — Storybook과 문서: Storybook 9 + MDX + Chromatic 시각 회귀

이 문서가 답하는 질문: 컴포넌트 라이브러리의 살아 있는 문서를 어떻게 만들고, 디자이너가 시각적 변화를 PR마다 검토할 수 있게 하며, Figma와 어떻게 연결하는가. 한 줄 답 (Pyramid Top): Storybook 9를 Vite 빌더 + autodocs MDX + Chromatic visual regression + Figma plugin의 4-축으로 세팅하면, 코드 변경 → Story 자동 갱신 → Chromatic이 픽셀 diff → 디자이너가 PR 댓글에서 승인하는 디자이너-개발자 단일 워크플로가 만들어진다.


Why — 왜 Storybook이 디자인 시스템의 단일 진실 공급원인가

문서는 5종이 필요하다:

종류대상도구
API 레퍼런스개발자TypeDoc, autodocs
사용 가이드개발자Nextra, MDX
시각 카탈로그디자이너·POStorybook
시각 회귀 테스트디자이너·QAChromatic / Percy
Figma ↔ 코드 매핑디자이너Figma plugin

이 다섯 종을 각각 다른 도구로 만들면 어긋난다 — Storybook이 v1, MDX 가이드가 v0.9, Figma가 v1.1을 그리는 상태. Storybook 8/9는 MDX와 autodocs로 (1)·(2)·(3)을 통합했고, Chromatic이 *(4)*를, @storybook/addon-designs가 *(5)*를 묶었다.

풀려는 문제이전 해법한계
컴포넌트 props 문서JSDoc + 별도 페이지코드와 분리, 어긋남
시각 카탈로그별도 Next.js 앱빌드 부담, 디자이너 접근성 ↓
시각 회귀screenshot diff 직접 구현인프라 부담
Figma 매핑위키 페이지갱신 안 됨

How — Storybook 9의 4-축


What — 실제 설정과 코드

apps/docs/.storybook/main.ts

import type { StorybookConfig } from '@storybook/react-vite';
 
const config: StorybookConfig = {
  framework: {
    name: '@storybook/react-vite',
    options: {},
  },
  stories: [
    '../src/**/*.mdx',
    '../../packages/react/src/**/*.stories.@(ts|tsx)',
  ],
  addons: [
    '@storybook/addon-essentials',
    '@storybook/addon-a11y',
    '@storybook/addon-themes',
    '@storybook/addon-designs',          // Figma embed
    '@storybook/addon-interactions',     // play function
  ],
  docs: {
    autodocs: 'tag',                    // tags: ['autodocs'] 붙은 것만
  },
  typescript: {
    reactDocgen: 'react-docgen-typescript',
    reactDocgenTypescriptOptions: {
      shouldExtractLiteralValuesFromEnum: true,
      propFilter: (prop) =>
        !/node_modules/.test(prop.parent?.fileName ?? ''),
    },
  },
  staticDirs: ['../public'],
  viteFinal: async (config) => {
    // 토큰 CSS 자동 주입
    return config;
  },
};
 
export default config;

apps/docs/.storybook/preview.tsx

import type { Preview } from '@storybook/react';
import { withThemeByDataAttribute } from '@storybook/addon-themes';
import '@org/tokens/dist/css/tokens.css';
import '@org/react/styles.css';
import '../src/styles/preview.css';
 
const preview: Preview = {
  parameters: {
    layout: 'centered',
    backgrounds: { disable: true },     // theme addon에 맡김
    docs: {
      toc: { headingSelector: 'h2, h3' },
      source: { language: 'tsx' },
    },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
  decorators: [
    withThemeByDataAttribute({
      themes: { light: 'light', dark: 'dark' },
      defaultTheme: 'light',
      attributeName: 'data-theme',
    }),
  ],
};
 
export default preview;

Story 파일: Button.stories.tsx

import type { Meta, StoryObj } from '@storybook/react';
import { expect, within, userEvent } from '@storybook/test';
import { Button } from './Button';
 
const meta = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],                   // ← autodocs 활성화
  parameters: {
    design: {
      type: 'figma',
      url: 'https://www.figma.com/file/XXX/Design-System?node-id=12-34',
    },
    docs: {
      description: {
        component: `
**Button**은 사용자의 명시적 행동을 받는 인터랙티브 요소다.
\`variant\`로 의미(primary/secondary/danger)를, \`size\`로 크기를 조절한다.
        `,
      },
    },
  },
  argTypes: {
    variant: {
      control: { type: 'select' },
      options: ['primary', 'secondary', 'danger'],
      description: '버튼의 의미적 역할',
      table: { defaultValue: { summary: 'primary' } },
    },
    size: {
      control: { type: 'inline-radio' },
      options: ['sm', 'md', 'lg', 'xl'],
    },
    disabled: { control: 'boolean' },
  },
} satisfies Meta<typeof Button>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const Primary: Story = {
  args: { variant: 'primary', children: '저장' },
};
 
export const Sizes: Story = {
  render: () => (
    <div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
      <Button size="sm">Small</Button>
      <Button size="md">Medium</Button>
      <Button size="lg">Large</Button>
      <Button size="xl">XLarge</Button>
    </div>
  ),
};
 
export const InteractionTest: Story = {
  args: { variant: 'primary', children: '클릭' },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);
    const btn = canvas.getByRole('button', { name: '클릭' });
    await userEvent.click(btn);
    await expect(btn).toHaveAttribute('data-clicked');
  },
};

tags: ['autodocs']가 붙은 컴포넌트는 자동으로 Docs 탭에 ArgsTable·Source·Story 임베드가 생긴다. props 설명은 TypeScript 타입에서 자동 추출된다.

MDX 가이드: Button.mdx

import { Canvas, Meta, ArgTypes, Source } from '@storybook/blocks';
import * as ButtonStories from './Button.stories';
 
<Meta of={ButtonStories} />
 
# Button
 
명시적 사용자 행동을 받는 컴포넌트.
 
## 언제 쓰나
 
- 폼 제출, 모달 확인, 페이지 이동 **의 액션
- 결과가 *즉시 발생*하는 인터랙션 (라우팅이 아니라)
 
링크가 필요하면 `<Link>`를 쓴다.
 
## Variants
 
<Canvas of={ButtonStories.Primary} />
 
## Sizes
 
<Canvas of={ButtonStories.Sizes} />
 
## Props
 
<ArgTypes of={ButtonStories.Primary} />
 
## Accessibility
 
- `role="button"`이 자동 부여
- `disabled` 시 포커스 불가, `aria-disabled` 부여
- Enter·Space로 활성화

이 MDX는 Storybook의 Docs 탭에 그대로 렌더된다. Story와 가이드가 같은 파일에서 import되어 어긋날 수 없다.


Chromatic 시각 회귀

.github/workflows/chromatic.yml:

name: Chromatic
 
on:
  push:
    branches: [main]
  pull_request:
 
jobs:
  chromatic:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
 
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: pnpm }
 
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo run build --filter=@org/react
 
      - name: Publish to Chromatic
        uses: chromaui/action@latest
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
          workingDir: apps/docs
          buildScriptName: build-storybook
          onlyChanged: true              # turborepo와 연동
          exitZeroOnChanges: true        # PR 댓글로 처리, CI는 통과

PR에 자동 댓글:

🦋 Chromatic Build #142
✅ 0 new components
⚠️  2 stories with visual changes — review required
  - Button — Sizes
  - Dialog — Default

[Review changes](https://www.chromatic.com/build?appId=...)

디자이너가 Chromatic 웹에서 픽셀 diff를 보고 Accept/Reject. Reject 시 PR이 시각 회귀 실패로 마킹.

시각 회귀의 결정성

같은 컴포넌트가 매번 다르게 렌더되면 false positive가 폭주한다. 해결:

// Button.stories.tsx
export const Primary: Story = {
  args: { variant: 'primary', children: '저장' },
  parameters: {
    chromatic: {
      // 폰트 로드 대기 — 한글 폰트가 늦게 그려지는 문제 회피
      delay: 300,
      // 다중 viewport
      viewports: [375, 768, 1280],
      // diff threshold (0~1)
      diffThreshold: 0.1,
    },
  },
};

추가로:

  • 애니메이션 비활성화: parameters.chromatic.pauseAnimationAtEnd: true
  • 랜덤 값 고정: Story 내에서 Math.random 호출 금지
  • 시간 고정: Date.now()를 직접 호출하지 말고 prop으로

Figma plugin: Tokens Studio → DTCG → 코드

@storybook/addon-designsparameters.design이 Story에 Figma 노드 URL을 박는다. Story 페이지의 “Design” 탭에서 Figma 프레임이 바로 보임. 디자이너는 코드 결과Figma 원본을 같은 화면에서 비교.


What-if — 잘못 쓰면 어떻게 깨지는가

  • 함정 1 — Storybook의 Vite와 Next.js의 Webpack 설정 충돌: 사용자 앱이 Next.js (Webpack)인데 Storybook은 Vite 빌더 → PostCSS 설정·alias 해석 어긋남. 컴포넌트가 Storybook에서는 작동, Next.js에서는 깨짐. 대응: .storybook/main.tsviteFinal에서 Next.js와 같은 alias·PostCSS를 강제로 동기화. 또는 Webpack 빌더를 선택 (@storybook/react-webpack5).

  • 함정 2 — RSC 컴포넌트가 Storybook에서 안 뜸: "use client" 없는 서버 컴포넌트를 Story로 등록 → async 컴포넌트가 Storybook 렌더러를 깨뜨림. 대응: Storybook은 클라이언트 컴포넌트만 Story로. 서버 컴포넌트는 MDX 예제 코드로만 표시.

  • 함정 3 — Chromatic 비용 폭증: 모든 PR마다 모든 Story를 캡처 → 한 달 10만 snapshot. 대응: onlyChanged: true + externals 설정으로 변경된 패키지의 Story만 캡처.

  • 함정 4 — 폰트 늦게 로드되어 false positive: 한글 웹폰트가 비동기 로드. Chromatic 캡처 시점에 fallback 폰트로 그려짐 → 글자 너비 다름 → diff. 대응: delay: 300 또는 폰트 inline base64 (가능하면).

  • 함정 5 — autodocs가 복잡한 prop 못 다룸: variant: 'primary' | 'secondary' | ((ctx) => string) 같은 함수 타입은 ArgsTable에서 빈칸. 대응: argTypes명시적 제어 추가. 함수 타입은 MDX 가이드에서 별도 서술.

  • 함정 6 — Figma URL 권한 누락: parameters.design.urlprivate Figma. 사용자에게 안 보임. 대응: 디자인 시스템 Figma 파일은 공개 view 권한 (또는 링크 가진 사람만).

  • 함정 7 — Storybook이 prod 의존성에 흘러감: 본문 02에서 다룬 함정. apps/docsprivate이어야 하고, packages/reactdevDependencies에만 Storybook 관련 들어가야 한다.


Insight — Storybook의 위기와 부활

2022~2023년, Storybook은 위기에 있었다:

  • v6 → v7 마이그레이션 비용이 컸음
  • Webpack 5 빌더가 느림
  • Vite 진영(ladleHistoire(Vue 진영)가 대안으로 부상

**Storybook 7(2023)**가 Vite 빌더 1급화 + CSF3로 부활했고, **Storybook 8(2024)**는 반응 속도 3배·번들 50% 감소·MDX 2 → 3 통합으로 다시 표준이 됐다.

반전: 대안 도구(ladle)는 더 가볍지만 Figma·Chromatic·Designs 같은 생태계가 없었다. 디자인 시스템 도메인에서 번들 크기는 부차적 — 디자이너가 함께 쓸 수 있는 도구가 1순위였다.

2025년 Storybook 9는 test runner 통합(Vitest + Playwright)으로 Story가 곧 테스트가 되는 방향으로 진화 중이다. Story 하나가 시각 카탈로그 + 시각 회귀 + 인터랙션 테스트를 동시에 만족한다.


요약

  • Storybook 9 = autodocs + MDX + Chromatic + Figma plugin의 4-축.
  • Story 파일이 시각 카탈로그 + 시각 회귀 + 인터랙션 테스트의 단일 공급원.
  • Chromatic은 픽셀 diff를 자동 검출하고 PR 댓글로 디자이너에게 검토 요청.
  • 함정 셋: 빌더 불일치(Vite vs Webpack), RSC 호환, 폰트 로딩 false positive.