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 |
| 시각 카탈로그 | 디자이너·PO | Storybook |
| 시각 회귀 테스트 | 디자이너·QA | Chromatic / 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-designs의 parameters.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.ts의viteFinal에서 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.url이 private Figma. 사용자에게 안 보임. 대응: 디자인 시스템 Figma 파일은 공개 view 권한 (또는 링크 가진 사람만). -
함정 7 — Storybook이 prod 의존성에 흘러감: 본문 02에서 다룬 함정.
apps/docs는 private이어야 하고,packages/react의devDependencies에만 Storybook 관련 들어가야 한다.
Insight — Storybook의 위기와 부활
2022~2023년, Storybook은 위기에 있었다:
- v6 → v7 마이그레이션 비용이 컸음
- Webpack 5 빌더가 느림
- Vite 진영(
ladle)·Histoire(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.