Storybook 왜 쓰는지 이제야 알겠다
컴포넌트 문서화 도구를 넘어 디자인 시스템 구축까지
Storybook. 프론트엔드 개발자라면 한 번쯤은 들어봤을 겁니다. "컴포넌트 문서화 도구"라는 건 알겠는데, 막상 실무에서 어떻게 써야 하는지 감이 안 오는 분들이 많을 거라 생각합니다.
저도 처음에는 그랬습니다. 공식 문서 보면서 따라 해봐도 "그래서 이걸 왜 쓰는 건데?"라는 생각이 들었고, 회사에서 디자인 시스템 구축하면서 직접 써보고 나서야 제대로 이해하게 됐습니다.
이번 글에서는 Storybook을 처음 접하는 분들도 바로 실무에 적용할 수 있도록, 개념부터 실제 구현까지 정리해보려고 합니다.
Storybook이란
Storybook은 UI 컴포넌트를 독립적으로 개발하고 문서화할 수 있는 도구입니다.
쉽게 말해서, 컴포넌트를 앱에서 분리해서 따로 볼 수 있는 환경을 만들어주는 겁니다. 버튼 하나를 테스트하려고 전체 앱을 띄울 필요 없이, 그 버튼만 독립적으로 확인하고 수정할 수 있습니다.
왜 필요한가
실무에서 겪었던 상황들을 예로 들어보겠습니다.
- "이 버튼 disabled 상태 어떻게 생겼어요?" - 기획자나 디자이너가 물어볼 때마다 앱 띄워서 보여주기 번거로움
- "이 컴포넌트 props가 뭐가 있더라?" - 매번 코드 뒤져보기 귀찮음
- "새로 만든 컴포넌트 QA 어떻게 하지?" - 전체 앱 빌드 없이 컴포넌트만 테스트하고 싶음
- "디자인 시스템 문서 어디 있어요?" - 문서 따로 만들기 귀찮은데 자동으로 됐으면...
Storybook을 쓰면 이런 문제들이 해결됩니다. 컴포넌트별로 모든 상태(variant, size, disabled 등)를 한눈에 볼 수 있고, 코드 예제까지 같이 보여줄 수 있거든요.
설치
기존 프로젝트에 Storybook을 추가하는 건 정말 간단합니다.
npx storybook@latest init이 명령어 하나면 끝입니다. Storybook이 알아서 프로젝트 환경(React, Vue, Vite, Webpack 등)을 감지하고 필요한 설정을 해줍니다.
설치가 완료되면 다음 명령어로 실행할 수 있습니다.
npm run storybook기본적으로 http://localhost:6006에서 Storybook UI를 확인할 수 있습니다.
프로젝트 구조
설치하면 다음과 같은 구조가 생깁니다.
.storybook/
main.ts # Storybook 메인 설정
preview.ts # 스토리 렌더링 설정
src/
stories/ # 예제 스토리들 (삭제해도 됨)
main.ts 설정
import type { StorybookConfig } from '@storybook/react-vite'
const config: StorybookConfig = {
// 스토리 파일 경로 패턴
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
// 사용할 애드온들
addons: [
'@storybook/addon-essentials',
'@storybook/addon-themes',
],
// 프레임워크 설정
framework: '@storybook/react-vite',
}
export default configstories 배열에서 스토리 파일을 어디서 찾을지 정의합니다. 보통 컴포넌트 파일 옆에 .stories.tsx 파일을 두는 방식을 많이 씁니다.
preview.ts 설정
import type { Preview } from '@storybook/react-vite'
import '../src/styles/global.css' // 글로벌 스타일 임포트
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
options: {
storySort: {
order: ['Foundations', 'Design System'],
},
},
},
}
export default previewpreview.ts에서는 모든 스토리에 공통으로 적용될 설정을 정의합니다. 글로벌 CSS를 여기서 임포트해야 스토리에서도 스타일이 적용됩니다.
첫 번째 스토리 작성하기
이제 실제로 스토리를 작성해보겠습니다. Button 컴포넌트가 있다고 가정하겠습니다.
interface ButtonProps {
variant?: 'default' | 'primary' | 'destructive'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
children: React.ReactNode
onClick?: () => void
}
export function Button({
variant = 'default',
size = 'md',
disabled = false,
children,
onClick
}: ButtonProps) {
return (
<button
className={cn(
'rounded-md font-medium',
variants[variant],
sizes[size],
disabled && 'opacity-50 cursor-not-allowed'
)}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
)
}기본 스토리 구조
import type { Meta, StoryObj } from '@storybook/react'
import { Button } from './button'
// 메타 정보: 스토리북 사이드바에 어떻게 표시될지 정의
const meta: Meta<typeof Button> = {
title: 'Design System/Button', // 사이드바 경로
component: Button, // 대상 컴포넌트
}
export default meta
type Story = StoryObj<typeof Button>
// 각각의 스토리 정의
export const Default: Story = {
args: {
children: '버튼',
},
}
export const Primary: Story = {
args: {
variant: 'primary',
children: '버튼',
},
}
export const Disabled: Story = {
args: {
disabled: true,
children: '버튼',
},
}이렇게 하면 Storybook 사이드바에 "Design System > Button" 경로로 스토리가 나타나고, Default, Primary, Disabled 세 가지 상태를 각각 확인할 수 있습니다.
title에 슬래시(/)를 쓰면 폴더 구조처럼 계층을 만들 수 있습니다. 'Foundations/Colors', 'Design System/Button' 이런 식으로 구성하면 됩니다.
Args와 Controls
위 예제에서 args라는 걸 봤을 텐데, 이게 Storybook의 핵심 기능 중 하나입니다.
args는 컴포넌트에 전달할 props를 정의하는 겁니다. 그리고 Storybook은 이 args를 기반으로 자동으로 Controls 패널을 생성합니다.
export const Playground: Story = {
args: {
variant: 'default',
size: 'md',
disabled: false,
children: '버튼',
},
}이렇게 하면 Storybook UI에서 variant, size, disabled 등을 드롭다운이나 체크박스로 직접 조작해볼 수 있습니다. 기획자나 디자이너한테 보여줄 때 정말 유용합니다.
argTypes로 컨트롤 커스터마이징
const meta: Meta<typeof Button> = {
title: 'Design System/Button',
component: Button,
argTypes: {
variant: {
control: 'select',
options: ['default', 'primary', 'destructive'],
description: '버튼 스타일',
},
size: {
control: 'radio',
options: ['sm', 'md', 'lg'],
description: '버튼 크기',
},
onClick: {
action: 'clicked', // 클릭 이벤트 로깅
},
},
}실무에서 쓰는 패턴: 갤러리 방식
여기까지가 기본적인 사용법인데, 제가 실무에서 디자인 시스템을 구축하면서 느낀 건 Controls 방식보다 갤러리 방식이 더 유용하다는 거였습니다.
Controls 방식은 하나씩 조작해봐야 하는데, 디자인 시스템 문서로 쓰려면 모든 상태를 한눈에 보여주는 게 더 낫거든요.
ExampleRow 컴포넌트 만들기
먼저 스토리용 유틸리티 컴포넌트를 만듭니다.
import type { ReactNode } from 'react'
import { CopyButton } from './copy-button'
interface ExampleRowProps {
label: string // 왼쪽에 표시할 라벨
code: string // 복사할 코드
children: ReactNode // 실제 컴포넌트
}
export function ExampleRow({ label, code, children }: ExampleRowProps) {
return (
<div className="flex items-center justify-between border-b px-4 py-3 last:border-b-0">
<div className="flex flex-1 items-center gap-4">
<span className="w-32 shrink-0 text-sm text-gray-500">{label}</span>
<div className="w-40">{children}</div>
</div>
<CopyButton code={code} />
</div>
)
}import { useState } from 'react'
export function CopyButton({ code }: { code: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = () => {
navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={handleCopy}
className="rounded bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200"
>
{copied ? 'Copied!' : 'Copy'}
</button>
)
}갤러리 방식 스토리 작성
import type { Meta, StoryObj } from '@storybook/react'
import { ExampleRow } from '@/stories/components'
import { Button } from './button'
const meta: Meta<typeof Button> = {
title: 'Design System/Button',
component: Button,
parameters: {
controls: { disable: true }, // Controls 패널 비활성화
actions: { disable: true },
},
}
export default meta
type Story = StoryObj<typeof Button>
export const Button_: Story = {
render: () => (
<div className="flex flex-col gap-8">
{/* Variants 섹션 */}
<section>
<h2 className="mb-2 text-sm font-medium">Variants</h2>
<div className="rounded-lg border">
<ExampleRow label="Default" code="<Button>버튼</Button>">
<Button>버튼</Button>
</ExampleRow>
<ExampleRow label="Primary" code='<Button variant="primary">버튼</Button>'>
<Button variant="primary">버튼</Button>
</ExampleRow>
<ExampleRow label="Destructive" code='<Button variant="destructive">버튼</Button>'>
<Button variant="destructive">버튼</Button>
</ExampleRow>
</div>
</section>
{/* Sizes 섹션 */}
<section>
<h2 className="mb-2 text-sm font-medium">Sizes</h2>
<div className="rounded-lg border">
<ExampleRow label="Small" code='<Button size="sm">버튼</Button>'>
<Button size="sm">버튼</Button>
</ExampleRow>
<ExampleRow label="Medium" code='<Button size="md">버튼</Button>'>
<Button size="md">버튼</Button>
</ExampleRow>
<ExampleRow label="Large" code='<Button size="lg">버튼</Button>'>
<Button size="lg">버튼</Button>
</ExampleRow>
</div>
</section>
{/* States 섹션 */}
<section>
<h2 className="mb-2 text-sm font-medium">States</h2>
<div className="rounded-lg border">
<ExampleRow label="Disabled" code="<Button disabled>버튼</Button>">
<Button disabled>버튼</Button>
</ExampleRow>
</div>
</section>
</div>
),
}이렇게 하면 한 페이지에서 Button의 모든 상태를 한눈에 확인할 수 있고, 각 예제 옆에 코드 복사 버튼까지 있어서 바로 가져다 쓸 수 있습니다.
Decorators: 공통 레이아웃 적용하기
모든 스토리에 공통으로 적용할 래퍼가 필요할 때가 있습니다. 예를 들어 다크모드를 지원하는 경우, 배경색과 텍스트 색을 자동으로 적용해야 하죠.
스토리별 데코레이터
const meta: Meta<typeof Button> = {
title: 'Design System/Button',
component: Button,
decorators: [
Story => (
<div className="min-h-screen bg-background p-6 text-foreground">
<Story />
</div>
),
],
}전역 데코레이터 (preview.ts)
import { withThemeByClassName } from '@storybook/addon-themes'
const preview: Preview = {
decorators: [
withThemeByClassName({
themes: {
light: '',
dark: 'dark',
},
defaultTheme: 'light',
}),
],
}@storybook/addon-themes를 사용하면 Storybook 툴바에서 라이트/다크 테마를 전환할 수 있습니다.
Tailwind CSS의 다크모드(dark: 클래스)를 사용한다면, 이 애드온이 dark 클래스를 자동으로 토글해줍니다.
Foundations 문서화
컴포넌트 외에도 디자인 토큰(색상, 타이포그래피, 아이콘 등)을 문서화할 수 있습니다.
Colors 스토리
import type { Meta, StoryObj } from '@storybook/react'
const colors = {
primary: '#3b82f6',
secondary: '#6b7280',
destructive: '#ef4444',
// ... 더 많은 색상
}
function ColorSwatch({ name, value }: { name: string; value: string }) {
const handleCopy = () => navigator.clipboard.writeText(value)
return (
<div
className="flex items-center gap-4 border-b px-4 py-3 cursor-pointer hover:bg-gray-50"
onClick={handleCopy}
>
<div
className="h-10 w-10 rounded border"
style={{ backgroundColor: value }}
/>
<span className="w-32 font-mono text-sm">{name}</span>
<span className="text-sm text-gray-500">{value}</span>
</div>
)
}
function Colors() {
return (
<div className="rounded-lg border">
{Object.entries(colors).map(([name, value]) => (
<ColorSwatch key={name} name={name} value={value} />
))}
</div>
)
}
const meta: Meta = {
title: 'Foundations/Colors',
component: Colors,
parameters: {
layout: 'fullscreen',
controls: { disable: true },
},
}
export default meta
export const Colors_: StoryObj = {}Typography 스토리
const fontSizes = {
xs: '0.75rem',
sm: '0.875rem',
base: '1rem',
lg: '1.125rem',
xl: '1.25rem',
'2xl': '1.5rem',
}
export const Typography_: Story = {
render: () => (
<div className="flex flex-col gap-8">
<section>
<h2 className="mb-2 text-sm font-medium">Font Sizes</h2>
<div className="rounded-lg border">
{Object.entries(fontSizes).map(([name, value]) => (
<div key={name} className="flex items-center border-b px-4 py-3">
<span className="w-16 text-sm text-gray-500">{name}</span>
<span className="w-20 text-xs text-gray-400">{value}</span>
<p style={{ fontSize: value }}>
가나다라 Typography 예시
</p>
</div>
))}
</div>
</section>
</div>
),
}디자인 토큰 객체를 순회하면서 자동으로 문서를 생성하는 방식입니다. 토큰이 추가되면 스토리도 자동으로 업데이트됩니다.
복잡한 컴포넌트: Dialog 예제
상태 관리가 필요한 컴포넌트도 스토리로 만들 수 있습니다.
export const Dialog_: Story = {
render: () => (
<div className="flex flex-col gap-8">
<section>
<h2 className="mb-2 text-sm font-medium">Basic Dialog</h2>
<div className="rounded-lg border">
<ExampleRow
label="Default"
code={`<Dialog>
<DialogTrigger asChild>
<Button variant="outline">다이얼로그 열기</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>제목</DialogTitle>
<DialogDescription>설명 텍스트</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">취소</Button>
</DialogClose>
<Button>확인</Button>
</DialogFooter>
</DialogContent>
</Dialog>`}
width="auto"
>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">다이얼로그 열기</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>제목</DialogTitle>
<DialogDescription>설명 텍스트</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">취소</Button>
</DialogClose>
<Button>확인</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ExampleRow>
</div>
</section>
</div>
),
}실제로 동작하는 Dialog를 보여주면서, 전체 코드도 함께 제공하는 방식입니다.
정리
좋은 점
- 컴포넌트 독립 개발: 전체 앱 없이 컴포넌트만 개발/테스트 가능
- 자동 문서화: 스토리가 곧 문서가 됨
- 협업 효율: 기획자/디자이너가 직접 컴포넌트 상태 확인 가능
- 시각적 회귀 테스트: Chromatic 같은 도구와 연동하면 UI 변경 감지 가능
- 디자인 시스템 구축: 일관된 UI 컴포넌트 라이브러리 관리
고려할 점
- 초기 설정 비용: 처음 세팅하고 패턴 잡는 데 시간 필요
- 유지보수: 컴포넌트 변경 시 스토리도 함께 업데이트 필요
- 빌드 시간: 스토리가 많아지면 빌드 시간 증가
추천하는 방식
- 작은 프로젝트: 기본 args/controls 방식으로 간단하게
- 디자인 시스템: ExampleRow 갤러리 방식으로 모든 상태 문서화
- 테마 지원 필요: addon-themes로 라이트/다크 전환
Storybook은 처음엔 "이걸 왜 써야 하지?" 싶을 수 있는데, 막상 컴포넌트가 많아지고 팀원이 늘어나면 진가를 발휘합니다. 특히 디자인 시스템을 구축한다면 거의 필수라고 생각합니다.