Storybook으로 컴포넌트 개발하기: Story 작성, Args·Actions, Decorators, Addons, 비주얼 테스트

이 글의 핵심

Storybook에서 컴포넌트를 앱과 분리해 상태별로 Story를 만드는 법, Args로 props를 조작하고 Actions로 이벤트를 확인하는 법, Decorators와 Addons, 비주얼 회귀 테스트를 다룹니다.

이 글의 핵심

Storybook으로 UI 컴포넌트를 개발하고 문서화하는 글입니다. Stories, Args, Actions, Addons, Visual Testing을 예제로 정리했습니다. 본문 설정은 Storybook 7~8 기준이며, 9 버전에서 바뀐 부분은 해당 위치에 따로 적었습니다.

실무에서 마주치는 문제들

컴포넌트를 격리해서 개발하기 어려워요

결제 완료 화면의 버튼 하나를 고치려고 로그인하고, 장바구니에 상품을 담고, 결제 단계까지 진행해야 하는 상황을 떠올리면 됩니다. Storybook은 컴포넌트를 앱의 라우팅·인증·API와 분리된 별도 개발 서버에서 띄우므로, 원하는 컴포넌트를 원하는 props로 바로 볼 수 있습니다.

모든 상태를 테스트하기 어려워요

로딩 중, 에러, 빈 목록, 아주 긴 텍스트, 권한 없음 같은 상태는 실제 앱에서 재현하기 번거로워 확인이 빠지기 쉽습니다. Story는 “이 props 조합일 때의 화면”을 코드로 고정해 두는 것이라, 한 번 만들어 두면 누구나 클릭 한 번으로 같은 상태를 다시 볼 수 있고 나중에 자동 테스트의 입력으로도 쓰입니다.

문서화가 번거로워요

컴포넌트 문서를 별도로 쓰면 코드가 바뀔 때 문서가 뒤처집니다. Storybook의 autodocs는 컴포넌트의 TypeScript 타입과 Story에서 props 표와 예시를 생성하므로 코드와 문서가 함께 움직입니다.

대신 Storybook도 유지해야 하는 코드입니다. 컴포넌트가 전역 상태, 라우터, 데이터 페칭에 강하게 묶여 있으면 Story마다 이를 흉내 내는 설정이 필요해지고, 이 비용이 커지면 Story가 방치되어 깨진 채로 남습니다. Storybook을 잘 쓰는 팀일수록 props만으로 그릴 수 있는 표현 컴포넌트와 데이터를 가져오는 컨테이너를 나누는 경향이 있는데, Storybook이 그런 설계를 자연스럽게 유도하는 측면도 있습니다.


Storybook으로 컴포넌트를 격리해 개발하는 이유

Storybook은 UI 컴포넌트 개발 도구입니다. 주요 장점:

  • 격리된 개발: 컴포넌트만 개발
  • 모든 상태: Stories로 정의
  • 자동 문서화: Props, Events
  • Addons: 수백 개의 확장
  • Visual Testing: 시각적 회귀 테스트

설치와 .storybook/main.ts 설정

React 프로젝트

npx storybook@latest init

init은 프로젝트의 프레임워크(React, Vue 등)와 빌드 도구(Vite, Webpack)를 감지해 맞는 패키지를 설치하고, .storybook/ 설정 폴더와 예제 Story, storybook·build-storybook 스크립트를 추가합니다. 대부분은 이 명령으로 충분하며, 수동 설치는 모노레포처럼 감지가 잘 안 되는 경우에 씁니다. Storybook 패키지들은 서로 같은 버전이어야 하므로, 일부만 업데이트하면 Storybook packages are not the same version 류의 경고와 함께 빌드가 깨질 수 있습니다. 업그레이드는 npx storybook@latest upgrade로 한꺼번에 하는 것이 안전합니다.

수동 설치

npm install -D @storybook/react @storybook/react-vite storybook

.storybook/main.ts

import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
  stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
  ],
  framework: {
    name: '@storybook/react-vite',
    options: {},
  },
};
export default config;

stories는 Story 파일을 찾을 glob 패턴입니다. 새 Story 파일이 사이드바에 나타나지 않는다면 이 패턴에 경로나 확장자가 맞는지부터 확인합니다(MDX 문서를 쓴다면 *.mdx도 추가해야 합니다). addon-essentials는 Controls, Actions, Docs, Viewport, Backgrounds, Toolbars 등 자주 쓰는 애드온 묶음입니다. Storybook 9에서는 이 기능들이 대부분 코어로 들어가 addon-essentials와 addon-interactions가 별도 패키지로 필요하지 않게 되었으므로, 9 이상으로 올린다면 이 목록을 정리해야 합니다.

Storybook은 앱과 별개의 빌드 설정으로 컴포넌트를 번들합니다. 그래서 앱에서 쓰는 경로 별칭(@/components), 전역 CSS, 환경 변수가 Storybook에서는 적용되지 않아 Failed to resolve import "@/..." 같은 에러가 나는 경우가 흔합니다. react-vite 프레임워크는 프로젝트의 vite.config를 읽어 별칭을 대부분 가져오지만, Webpack 기반이라면 webpackFinal에서 별칭을 직접 추가해야 합니다.


Button 컴포넌트로 첫 Story 작성

Button 컴포넌트

// src/components/Button.tsx
interface ButtonProps {
  label: string;
  variant?: 'primary' | 'secondary';
  size?: 'small' | 'medium' | 'large';
  onClick?: () => void;
}
export default function Button({ label, variant = 'primary', size = 'medium', onClick }: ButtonProps) {
  return (
    <button className={`btn btn-${variant} btn-${size}`} onClick={onClick}>
      {label}
    </button>
  );
}

Story

// src/components/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import Button from './Button';
const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary'],
    },
    size: {
      control: 'select',
      options: ['small', 'medium', 'large'],
    },
  },
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
  args: {
    label: 'Primary Button',
    variant: 'primary',
  },
};
export const Secondary: Story = {
  args: {
    label: 'Secondary Button',
    variant: 'secondary',
  },
};
export const Small: Story = {
  args: {
    label: 'Small Button',
    size: 'small',
  },
};
export const Large: Story = {
  args: {
    label: 'Large Button',
    size: 'large',
  },
};

Story 파일은 CSF(Component Story Format)라는 규칙을 따릅니다. export default로 내보낸 meta는 이 파일의 모든 Story에 공통인 설정(사이드바 경로인 title, 대상 component, 컨트롤 설정)이고, 이름 있는 export 하나하나가 Story 하나입니다. title을 생략하면 파일 경로를 기준으로 자동 생성됩니다. argTypes의 control: 'select'는 Controls 패널에 드롭다운을 만들어 주는데, TypeScript 유니언 타입('primary' | 'secondary')에서 대부분 자동으로 추론되므로 추론이 틀릴 때만 명시해도 됩니다.

Story를 쓸 때 흔히 하는 실수는 Primary, Secondary처럼 props 값 하나만 바꾼 Story를 잔뜩 만드는 것입니다. 그런 변형은 Controls 패널에서 바로 바꿔 볼 수 있으므로, Story는 “의미 있는 상태”(비활성화, 로딩, 아주 긴 라벨, 아이콘만 있는 버튼)를 기준으로 만드는 편이 문서로서도, 테스트 입력으로서도 가치가 큽니다.


Args로 props 조작하고 Actions로 이벤트 기록하기

Args

Args는 Story에 넘기는 props 값입니다. Controls 패널에서 값을 바꾸면 args가 바뀌고 컴포넌트가 다시 렌더링됩니다. 아래 예제는 여기에 play 함수를 더해, Story가 렌더링된 뒤 사용자 동작(클릭)을 자동으로 실행합니다. within과 userEvent는 Storybook 8에서는 @storybook/test에서 가져옵니다.

import { within, userEvent } from '@storybook/test';
export const Interactive: Story = {
  args: {
    label: 'Click me',
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole('button');
    await userEvent.click(button);
  },
};

play 함수는 Testing Library와 같은 API를 씁니다. canvasElement는 Story가 그려진 DOM 영역이고, getByRole('button')처럼 접근성 역할로 요소를 찾습니다. 클릭 후 expect로 결과를 검증하면 Story가 곧 상호작용 테스트가 되고, Interactions 패널에서 각 단계를 되감아 볼 수 있습니다. 드롭다운을 연 상태, 폼 검증 에러가 표시된 상태처럼 props만으로는 만들 수 없는 화면을 Story로 남길 때 특히 유용합니다. userEvent 호출 앞의 await를 빠뜨리면 이벤트가 끝나기 전에 다음 단계가 실행되어, 가끔만 실패하는 불안정한 테스트가 됩니다.

Actions

Actions는 컴포넌트가 호출한 콜백(onClick 등)을 Actions 패널에 기록해, 이벤트가 어떤 인자로 발생했는지 보여줍니다.

import { action } from '@storybook/addon-actions';
export const WithAction: Story = {
  args: {
    label: 'Click me',
    onClick: action('button-clicked'),
  },
};

Storybook 8부터는 action() 대신 @storybook/test의 fn()을 쓰는 것이 권장됩니다. onClick: fn()으로 넘기면 Actions 패널에 기록되는 것은 같고, 추가로 play 함수에서 expect(args.onClick).toHaveBeenCalled()처럼 호출 여부를 검증할 수 있는 스파이가 됩니다. 이전 버전에서 쓰던 argTypesRegex: '^on.*' 설정은 on으로 시작하는 props에 자동으로 action을 넣어 주었지만, 이 방식은 play 함수에서 검증할 수 없어 8에서 권장되지 않게 되었습니다.


전역·Story별 Decorator

전역 Decorator

// .storybook/preview.tsx
import type { Preview } from '@storybook/react';
import '../src/index.css';
const preview: Preview = {
  decorators: [
    (Story) => (
      <div style={{ padding: '3rem' }}>
        <Story />
      </div>
    ),
  ],
};
export default preview;

Decorator는 Story를 감싸는 래퍼 컴포넌트입니다. preview.tsx에 넣은 전역 Decorator는 모든 Story에 적용되므로, 테마 Provider, i18n Provider, React Query의 QueryClientProvider, 라우터처럼 앱의 최상위에서 감싸던 것들을 여기에 둡니다. 컴포넌트 안에서 useNavigate를 쓰는데 Storybook에서 useNavigate() may be used only in the context of a <Router> component 에러가 나는 것이 전형적으로 Decorator가 빠진 경우입니다. import '../src/index.css'처럼 전역 CSS를 불러오지 않으면 Story에서 스타일이 전혀 적용되지 않는 것도 같은 이유입니다.

Decorator 적용 순서는 전역, 컴포넌트(meta), Story 순으로 바깥에서 안쪽입니다. 여러 Story가 같은 Provider를 쓰되 값만 다르다면, Decorator 안에서 context.args나 context.parameters를 읽어 Provider 값을 바꾸는 방식이 Story마다 Decorator를 복사하는 것보다 관리하기 쉽습니다.

Story별 Decorator

export const Centered: Story = {
  args: {
    label: 'Centered Button',
  },
  decorators: [
    (Story) => (
      <div style={{ display: 'flex', justifyContent: 'center' }}>
        <Story />
      </div>
    ),
  ],
};

Parameters로 배경 같은 Story 설정 바꾸기

export const DarkMode: Story = {
  args: {
    label: 'Dark Button',
  },
  parameters: {
    backgrounds: {
      default: 'dark',
    },
  },
};

Parameters는 Story에 붙이는 정적 메타데이터로, 컴포넌트 props가 아니라 애드온의 동작을 설정합니다. 위 예제는 Backgrounds 애드온에게 이 Story를 어두운 배경으로 보여 달라고 지정한 것입니다. Args와 달리 Controls 패널에서 바꿀 수 없고, 전역·컴포넌트·Story 단계에서 지정한 값이 병합됩니다. 레이아웃(layout: 'centered'), 뷰포트 기본값, 비주얼 테스트에서 이 Story를 건너뛸지 같은 설정도 parameters로 합니다.

배경색만 바꾸는 것은 컴포넌트의 다크 모드를 테스트하는 것이 아니라는 점에 주의합니다. 컴포넌트가 dark 클래스나 CSS 변수로 테마를 바꾼다면, Decorator로 그 클래스나 Provider를 적용해야 실제 다크 모드 스타일이 확인됩니다. Storybook 9에서는 배경 설정이 globals 기반으로 바뀌었으므로, 버전을 올릴 때 이 부분의 마이그레이션 안내를 확인해야 합니다.


Addon 추가하기

인기 Addons

npm install -D @storybook/addon-a11y
npm install -D @storybook/addon-viewport
npm install -D @storybook/addon-storysource

.storybook/main.ts

const config: StorybookConfig = {
  addons: [
    '@storybook/addon-essentials',
    '@storybook/addon-a11y',
    '@storybook/addon-viewport',
    '@storybook/addon-storysource',
  ],
};

addon-a11y는 axe-core로 각 Story의 접근성 위반(명도 대비 부족, 라벨 없는 입력, 잘못된 ARIA 속성)을 검사해 패널에 보여줍니다. 디자인 시스템처럼 여러 화면에서 재사용되는 컴포넌트라면 가장 먼저 추가할 만한 애드온입니다. 반면 addon-viewport는 이미 addon-essentials에 포함되어 있어 따로 설치하지 않아도 되고, addon-storysource는 최근 버전에서 Docs 페이지의 코드 표시 기능으로 대체되어 유지보수가 중단되었습니다. 애드온은 Storybook 코어와 메이저 버전이 맞아야 하므로, 오래된 서드파티 애드온 하나 때문에 업그레이드가 막히는 일이 생깁니다. 꼭 필요한 것만 추가하는 편이 장기적으로 편합니다.


Vue 프로젝트에서 Storybook 쓰기

설치

npx storybook@latest init

Story

// src/components/Button.stories.ts
import type { Meta, StoryObj } from '@storybook/vue3';
import Button from './Button.vue';
const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
  args: {
    label: 'Primary Button',
    variant: 'primary',
  },
};

Vue에서도 CSF 형식과 args 개념은 같습니다. 차이는 렌더링 방식으로, args가 Vue 컴포넌트의 props로 전달되며 슬롯이나 v-model을 쓰는 컴포넌트는 render 함수에서 템플릿을 직접 지정해야 합니다. 예를 들어 v-model을 쓰는 입력 컴포넌트는 render: (args) => ({ components: { MyInput }, setup: () => ({ args }), template: '<MyInput v-bind="args" />' }) 형태로 씁니다. Pinia나 Vue Router를 쓰는 컴포넌트라면 .storybook/preview.ts의 setup()에서 앱 인스턴스에 플러그인을 등록해야 합니다.


Chromatic과 Test Runner로 시각적 회귀 테스트

기능 테스트는 “버튼을 누르면 콜백이 호출되는가”를 확인하지만, 패딩이 4px 바뀌었거나 폰트가 다른 것으로 대체된 문제는 잡지 못합니다. 비주얼 회귀 테스트는 각 Story를 브라우저에서 캡처해 이전 기준 이미지와 픽셀 단위로 비교하고, 달라진 부분을 사람이 승인하거나 거부하게 합니다. Story가 이미 모든 상태를 정의하고 있으므로 별도의 테스트 시나리오 없이 곧바로 비주얼 테스트 대상이 된다는 점이 Storybook과 잘 맞는 이유입니다.

Chromatic

npm install -D chromatic
npx chromatic --project-token=YOUR_TOKEN

Chromatic은 Storybook 팀이 운영하는 클라우드 서비스로, Storybook을 빌드해 올리면 모든 Story의 스냅샷을 찍어 이전 빌드와 비교합니다. 프로젝트 토큰은 명령줄에 직접 쓰지 말고 CI의 비밀 변수(CHROMATIC_PROJECT_TOKEN)로 넘겨야 합니다. 스냅샷 수에 따라 과금되므로 Story가 수백 개라면 변경된 Story만 테스트하는 TurboSnap 옵션을 검토할 만합니다.

비주얼 테스트를 처음 도입하면 흔히 겪는 문제는 코드를 바꾸지 않았는데도 차이가 나는 불안정한 스냅샷입니다. 현재 날짜를 표시하는 컴포넌트, 무작위 데이터, 애니메이션 도중 캡처, 웹폰트가 로드되기 전 캡처가 대표적인 원인입니다. Story에서 날짜와 데이터를 고정값으로 넘기고, 애니메이션은 비주얼 테스트 중에 끄는 설정을 두어야 “매번 승인 버튼만 누르는” 상태를 피할 수 있습니다. 비슷한 이유로 Story 안에서 실제 API를 호출하면 응답이 바뀔 때마다 스냅샷이 흔들리므로 MSW로 응답을 고정하는 것이 일반적입니다.

Storybook Test Runner

Test Runner는 Playwright로 모든 Story를 열어 렌더링 에러가 없는지, play 함수가 성공하는지 확인하는 도구입니다. Storybook 개발 서버나 정적 빌드가 떠 있어야 실행되며, 비주얼 비교 기능은 기본으로 들어 있지 않습니다. 최근 버전에서는 Vitest 애드온(@storybook/addon-vitest)으로 Story를 Vitest 테스트로 실행하는 방식이 권장 경로가 되고 있으므로, 새로 설정한다면 그쪽도 함께 검토하는 것이 좋습니다.

npm install -D @storybook/test-runner
{
  "scripts": {
    "test-storybook": "test-storybook"
  }
}

예제: Input 폼 컴포넌트 Story

Input 컴포넌트

// src/components/Input.tsx
interface InputProps {
  label: string;
  type?: 'text' | 'email' | 'password';
  placeholder?: string;
  error?: string;
  value?: string;
  onChange?: (value: string) => void;
}
export default function Input({ label, type = 'text', placeholder, error, value, onChange }: InputProps) {
  return (
    <div className="input-group">
      <label>{label}</label>
      <input
        type={type}
        placeholder={placeholder}
        value={value}
        onChange={(e) => onChange?.(e.target.value)}
        className={error ? 'error' : ''}
      />
      {error && <span className="error-message">{error}</span>}
    </div>
  );
}

Story

// src/components/Input.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import Input from './Input';
const meta: Meta<typeof Input> = {
  title: 'Components/Input',
  component: Input,
  tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof Input>;
export const Default: Story = {
  args: {
    label: 'Email',
    type: 'email',
    placeholder: '[email protected]',
  },
};
export const WithError: Story = {
  args: {
    label: 'Email',
    type: 'email',
    placeholder: '[email protected]',
    error: 'Invalid email address',
  },
};
export const Password: Story = {
  args: {
    label: 'Password',
    type: 'password',
    placeholder: 'Enter password',
  },
};

Input은 value와 onChange를 부모가 관리하는 제어 컴포넌트입니다. 그래서 이 Story들에서는 value를 넘기지 않아 입력은 되지만, value를 args로 고정하면 사용자가 입력해도 값이 바뀌지 않는 것처럼 보입니다. Story 안에서 입력을 살려 두려면 useArgs 훅으로 onChange 때 args를 갱신하거나, render 함수 안에서 useState로 값을 관리하는 래퍼를 만듭니다.

WithError 같은 에러 상태 Story는 실제 앱에서 재현하려면 폼을 제출하고 서버 검증 실패를 기다려야 하는 화면을 한 번에 보여 준다는 점에서 가치가 큽니다. 에러 메시지가 길어 레이아웃이 깨지는지, 스크린 리더가 에러를 읽을 수 있는지(aria-invalid, aria-describedby)를 이 Story에서 확인하면 됩니다. 참고로 현재 Input은 <label>과 <input>이 htmlFor/id로 연결되어 있지 않아, a11y 애드온을 켜면 “폼 요소에 라벨이 없다”는 경고가 나옵니다. Storybook이 이런 문제를 개발 단계에서 드러내 준다는 좋은 예입니다.


Storybook 도입 요약

  • Storybook: UI 컴포넌트 개발 도구
  • 격리된 개발: 컴포넌트만 개발
  • Stories: 모든 상태 정의
  • 자동 문서화: Props, Events
  • Addons: 수백 개의 확장
  • Visual Testing: 시각적 회귀 테스트

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 프로덕션 빌드에 포함되나요?

A. 아니요. Storybook은 별도 빌드(build-storybook)로 만들어지고 앱 번들과 섞이지 않습니다. 다만 *.stories.tsx 파일이 앱의 빌드 대상 경로에 있고 어딘가에서 import되면 번들에 들어갈 수 있으므로, 앱 코드가 Story 파일을 참조하지 않게 주의합니다.

Q. 디자이너와 공유할 수 있나요?

A. build-storybook으로 만든 정적 사이트를 사내 서버나 정적 호스팅에 올리면 디자이너와 기획자도 브라우저로 볼 수 있습니다. PR마다 미리보기를 배포해 두면 리뷰할 때 실제 화면을 함께 확인할 수 있습니다.

Q. 테스트 도구인가요?

A. 본래는 개발·문서화 도구지만, play 함수 기반 상호작용 테스트, 접근성 검사, 비주얼 회귀 테스트의 입력으로 Story를 재사용할 수 있어 테스트 전략의 일부로 쓰는 팀이 많습니다.

Q. 도입 비용은 어느 정도인가요?

A. 설치 자체는 간단하지만 Provider, 라우터, API 모킹 설정을 앱과 맞추는 초기 작업이 필요하고, 컴포넌트가 바뀔 때 Story도 함께 고쳐야 합니다. 재사용 컴포넌트가 많은 디자인 시스템이나 여러 사람이 같은 UI를 다루는 프로젝트일수록 이 비용 대비 효과가 큽니다.