Storybook 왜 쓰는지 이제야 알겠다

컴포넌트 문서화 도구를 넘어 디자인 시스템 구축까지

Frontend
2025년 6월 3일

Storybook. 프론트엔드 개발자라면 한 번쯤은 들어봤을 겁니다. "컴포넌트 문서화 도구"라는 건 알겠는데, 막상 실무에서 어떻게 써야 하는지 감이 안 오는 분들이 많을 거라 생각합니다.

저도 처음에는 그랬습니다. 공식 문서 보면서 따라 해봐도 "그래서 이걸 왜 쓰는 건데?"라는 생각이 들었고, 회사에서 디자인 시스템 구축하면서 직접 써보고 나서야 제대로 이해하게 됐습니다.

이번 글에서는 Storybook을 처음 접하는 분들도 바로 실무에 적용할 수 있도록, 개념부터 실제 구현까지 정리해보려고 합니다.

Storybook이란

Storybook은 UI 컴포넌트를 독립적으로 개발하고 문서화할 수 있는 도구입니다.

쉽게 말해서, 컴포넌트를 앱에서 분리해서 따로 볼 수 있는 환경을 만들어주는 겁니다. 버튼 하나를 테스트하려고 전체 앱을 띄울 필요 없이, 그 버튼만 독립적으로 확인하고 수정할 수 있습니다.

왜 필요한가

실무에서 겪었던 상황들을 예로 들어보겠습니다.

  1. "이 버튼 disabled 상태 어떻게 생겼어요?" - 기획자나 디자이너가 물어볼 때마다 앱 띄워서 보여주기 번거로움
  2. "이 컴포넌트 props가 뭐가 있더라?" - 매번 코드 뒤져보기 귀찮음
  3. "새로 만든 컴포넌트 QA 어떻게 하지?" - 전체 앱 빌드 없이 컴포넌트만 테스트하고 싶음
  4. "디자인 시스템 문서 어디 있어요?" - 문서 따로 만들기 귀찮은데 자동으로 됐으면...

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 설정

.storybook/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 config

stories 배열에서 스토리 파일을 어디서 찾을지 정의합니다. 보통 컴포넌트 파일 옆에 .stories.tsx 파일을 두는 방식을 많이 씁니다.

preview.ts 설정

.storybook/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 preview

preview.ts에서는 모든 스토리에 공통으로 적용될 설정을 정의합니다. 글로벌 CSS를 여기서 임포트해야 스토리에서도 스타일이 적용됩니다.

첫 번째 스토리 작성하기

이제 실제로 스토리를 작성해보겠습니다. Button 컴포넌트가 있다고 가정하겠습니다.

src/components/button/button.tsx
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>
  )
}

기본 스토리 구조

src/components/button/button.stories.tsx
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 컴포넌트 만들기

먼저 스토리용 유틸리티 컴포넌트를 만듭니다.

src/stories/components/example-row.tsx
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>
  )
}
src/stories/components/copy-button.tsx
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>
  )
}

갤러리 방식 스토리 작성

src/components/button/button.stories.tsx
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: 공통 레이아웃 적용하기

모든 스토리에 공통으로 적용할 래퍼가 필요할 때가 있습니다. 예를 들어 다크모드를 지원하는 경우, 배경색과 텍스트 색을 자동으로 적용해야 하죠.

스토리별 데코레이터

button.stories.tsx
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)

.storybook/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 스토리

src/stories/foundation/colors.stories.tsx
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 스토리

src/stories/foundation/typography.stories.tsx
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 예제

상태 관리가 필요한 컴포넌트도 스토리로 만들 수 있습니다.

src/components/dialog/dialog.stories.tsx
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를 보여주면서, 전체 코드도 함께 제공하는 방식입니다.

정리

좋은 점

  1. 컴포넌트 독립 개발: 전체 앱 없이 컴포넌트만 개발/테스트 가능
  2. 자동 문서화: 스토리가 곧 문서가 됨
  3. 협업 효율: 기획자/디자이너가 직접 컴포넌트 상태 확인 가능
  4. 시각적 회귀 테스트: Chromatic 같은 도구와 연동하면 UI 변경 감지 가능
  5. 디자인 시스템 구축: 일관된 UI 컴포넌트 라이브러리 관리

고려할 점

  1. 초기 설정 비용: 처음 세팅하고 패턴 잡는 데 시간 필요
  2. 유지보수: 컴포넌트 변경 시 스토리도 함께 업데이트 필요
  3. 빌드 시간: 스토리가 많아지면 빌드 시간 증가

추천하는 방식

  • 작은 프로젝트: 기본 args/controls 방식으로 간단하게
  • 디자인 시스템: ExampleRow 갤러리 방식으로 모든 상태 문서화
  • 테마 지원 필요: addon-themes로 라이트/다크 전환

Storybook은 처음엔 "이걸 왜 써야 하지?" 싶을 수 있는데, 막상 컴포넌트가 많아지고 팀원이 늘어나면 진가를 발휘합니다. 특히 디자인 시스템을 구축한다면 거의 필수라고 생각합니다.

참고 자료

Tags:
StorybookReactDesignSystemComponent