junyeokk
Blog
Testing·2025. 08. 15

Storybook 9 + Chromatic

프론트엔드 컴포넌트를 개발하다 보면 한 가지 근본적인 문제에 부딪힌다. 컴포넌트가 가질 수 있는 상태의 조합이 너무 많다는 것이다. 버튼 하나만 해도 default, hover, disabled, loading, 아이콘 포함, 아이콘 미포함, 크기 S/M/L 등 수십 가지 변형이 존재한다. 이걸 실제 페이지에서 하나하나 확인하려면 특정 조건을 만들기 위해 API를 조작하고, 상태를 변경하고, 네비게이션을 반복해야 한다. 비효율적일 뿐 아니라 빠뜨리기 쉽다.

Storybook은 이 문제를 해결하기 위해 만들어졌다. 컴포넌트를 애플리케이션에서 분리해서, 각각의 상태를 독립적으로 렌더링하고 확인할 수 있는 환경을 제공한다. 각 상태를 "Story"라고 부르며, 이 Story들을 한 곳에 모아 카탈로그처럼 탐색할 수 있다.

Story란 무엇인가

Story는 컴포넌트의 특정 상태를 선언적으로 정의한 것이다. 컴포넌트에 전달할 props(args)를 지정하면, Storybook이 그 상태로 컴포넌트를 렌더링해 준다.

tsx
// Button.stories.ts
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";

const meta: Meta<typeof Button> = {
  component: Button,
};
export default meta;

type Story = StoryObj<typeof Button>;

export const Primary: Story = {
  args: {
    variant: "primary",
    label: "Click me",
  },
};

export const Disabled: Story = {
  args: {
    variant: "primary",
    label: "Click me",
    disabled: true,
  },
};

export const Loading: Story = {
  args: {
    variant: "primary",
    label: "Submitting...",
    loading: true,
  },
};

meta 객체에서 대상 컴포넌트를 지정하고, 각 export가 하나의 Story가 된다. args에 props를 넣으면 해당 상태로 렌더링된다. Storybook UI에서는 이 args를 실시간으로 변경할 수 있는 Controls 패널도 제공한다.

Storybook 9의 핵심 변화

Storybook 9는 이전 버전 대비 설치 크기가 48% 줄었다. 의존성 구조가 평탄해져서 package.json에서 버전 충돌이 일어나는 문제도 크게 감소했다. 하지만 크기 감소보다 더 중요한 변화는 테스팅 통합이다.

Vitest 통합 — Storybook Test

기존에는 Storybook에서 인터랙션 테스트를 작성해도, 해당 Story로 직접 이동해야만 테스트가 실행됐다. Story가 100개면 100번 클릭해야 했다. Storybook 9는 Vitest와 통합되어, 모든 Story의 테스트를 한 번에 실행할 수 있다.

tsx
// LoginForm.stories.ts
import { expect, userEvent, within } from "@storybook/test";

export const SubmitSuccess: Story = {
  args: {
    onSubmit: fn(),
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);

    await userEvent.type(canvas.getByLabelText("Email"), "user@example.com");
    await userEvent.type(canvas.getByLabelText("Password"), "password123");
    await userEvent.click(canvas.getByRole("button", { name: "Login" }));

    await expect(args.onSubmit).toHaveBeenCalledWith({
      email: "user@example.com",
      password: "password123",
    });
  },
};

play 함수 안에서 사용자 행동을 시뮬레이션하고 결과를 검증한다. 이것이 인터랙션 테스트다. userEvent로 타이핑, 클릭 등을 수행하고, expect로 기대 결과를 단언한다. Storybook 9에서는 이 테스트들이 Vitest runner 위에서 실행되기 때문에 Watch 모드도 지원한다. 파일을 저장하면 관련 테스트만 자동으로 재실행된다.

접근성(a11y) 테스트

Storybook 9는 @storybook/addon-a11y를 통해 모든 Story에 대해 WCAG 접근성 위반을 자동 검사한다. 내부적으로는 업계 표준인 axe-core 엔진을 사용한다.

tsx
// 별도 설정 없이 addon 설치만 하면 동작
// .storybook/main.ts
const config: StorybookConfig = {
  addons: ["@storybook/addon-a11y"],
};

설치 후 Storybook UI의 Accessibility 패널에서 위반 사항을 확인할 수 있다. 색상 대비 부족, ARIA 속성 누락, 키보드 네비게이션 불가 등의 문제를 개발 단계에서 잡아낸다. 핵심은 "모든 Story에 대해 한 번에" 검사할 수 있다는 것이다. 기존에는 각 페이지를 브라우저에서 열어 Lighthouse나 axe 확장을 돌려야 했다면, 이제는 컴포넌트의 모든 변형 상태를 자동으로 검증한다.

Test Widget

Storybook 9 UI 하단에 Test Widget이 추가됐다. 버튼 하나로 인터랙션 테스트, 접근성 테스트, 비주얼 테스트를 모두 실행하고, 사이드바에서 경고나 에러가 있는 Story만 필터링해서 볼 수 있다. 이전에는 테스트 종류별로 별도 도구를 사용했지만, 이제 Storybook 안에서 모든 테스트를 관리한다.

Vite 기반 Next.js 프레임워크

Storybook 8까지 Next.js 프로젝트는 @storybook/nextjs라는 Webpack 기반 프레임워크를 사용했다. Storybook 9에서는 @storybook/nextjs-vite가 추가되어 Vite 기반으로 전환할 수 있다.

ts
// .storybook/main.ts
const config: StorybookConfig = {
  framework: {
    name: "@storybook/nextjs-vite",
    options: {},
  },
};

기능적으로는 Webpack 버전과 동일하다. next/image, next/font, next/navigation 등 Next.js 전용 API 모킹을 그대로 지원한다. 차이점은 빌드 속도다. Vite의 ESM 기반 HMR 덕분에 Story 파일 수정 시 반영 속도가 체감될 정도로 빨라진다. 또한 Vitest와 같은 Vite 생태계 도구와의 호환성도 자연스럽게 확보된다.

Story Globals

특정 Story를 항상 다크 모드로 보거나, 모바일 뷰포트로 고정하고 싶을 때가 있다. Storybook 9에서는 Story 수준에서 globals를 설정할 수 있다.

tsx
export const DarkMode: Story = {
  args: { label: "Button" },
  globals: { theme: "dark" },
};

export const Mobile: Story = {
  args: { label: "Button" },
  globals: { viewport: "mobile" },
};

이전에는 Storybook 툴바에서 수동으로 전환해야 했고, 그 설정은 Story 파일에 기록되지 않았다. 이제 코드에 명시적으로 선언하기 때문에 "이 Story는 항상 다크 모드로 봐야 한다"는 의도가 코드에 남는다. 비주얼 테스트에서도 이 globals가 적용된 상태로 스냅샷이 찍힌다.

Tag 기반 조직화

대규모 프로젝트에서 Story가 수백 개를 넘기면 사이드바 탐색이 어려워진다. Storybook 9에서는 Tag를 사용해 Story를 분류하고 필터링할 수 있다.

tsx
const meta: Meta<typeof Button> = {
  component: Button,
  tags: ["stable", "design-system"],
};

export const Experimental: Story = {
  tags: ["alpha"],
  args: { label: "New Button" },
};

사이드바에서 stable, alpha, deprecated 등의 태그로 필터링할 수 있다. 팀별(team:frontend, team:design), 기능 영역별(feature:auth, feature:payment), 상태별(stable, deprecated) 등 프로젝트에 맞게 자유롭게 태그를 정의할 수 있다.

Chromatic — 비주얼 리그레션 테스트

인터랙션 테스트와 접근성 테스트는 "기능이 작동하는가", "접근 가능한가"를 검증한다. 하지만 "보이는 대로 맞는가"는 별개의 문제다. CSS 한 줄 수정으로 전혀 다른 컴포넌트의 레이아웃이 깨질 수 있고, 이런 시각적 버그는 기존 테스트로는 잡을 수 없다.

Chromatic은 Storybook 메인테이너가 만든 비주얼 테스트 클라우드 서비스다. 동작 원리는 다음과 같다.

비주얼 테스트의 작동 방식

  1. 스냅샷 캡처: CI에서 chromatic 명령을 실행하면, 모든 Story를 클라우드 브라우저에서 렌더링하고 스크린샷을 찍는다.
  2. 픽셀 비교: 이전 빌드의 스냅샷과 현재 스냅샷을 픽셀 단위로 비교한다.
  3. 변경 감지: 차이가 발견되면 변경된 부분을 하이라이트해서 보여준다.
  4. 승인/거부: 개발자가 변경이 의도된 것인지 검토하고, 승인하면 새 스냅샷이 베이스라인이 된다.
bash
# CI에서 실행
npx chromatic --project-token=<token>

이 한 줄로 모든 Story에 대해 비주얼 테스트가 수행된다. GitHub PR에 체크로 연동되어, 시각적 변경이 있으면 PR이 블록된다.

왜 픽셀 비교인가

CSS 스냅샷 테스트(jest-styled-components 등)는 CSS 코드가 같은지를 비교한다. 하지만 같은 CSS라도 브라우저 렌더링 결과가 다를 수 있고, 다른 CSS라도 시각적으로 동일할 수 있다. 픽셀 비교는 실제 렌더링 결과를 비교하기 때문에 이런 오탐/미탐 문제가 없다.

단, 폰트 렌더링이나 안티앨리어싱 차이로 인한 노이즈가 발생할 수 있다. Chromatic은 이를 처리하기 위해 임계값(threshold)을 설정하고, 의미 없는 1-2픽셀 차이는 무시하는 로직을 내장하고 있다.

Storybook과의 통합

Chromatic은 Storybook 프로젝트와 자연스럽게 연동된다. @chromatic-com/storybook 애드온을 설치하면 된다.

ts
// .storybook/main.ts
const config: StorybookConfig = {
  addons: ["@chromatic-com/storybook"],
};

이 애드온은 두 가지 역할을 한다.

  1. 로컬 개발 시: Storybook UI에서 해당 Story의 최근 비주얼 테스트 결과를 바로 확인할 수 있다.
  2. CI 시: chromatic CLI가 Story 메타데이터를 읽어서 어떤 Story를 캡처할지, 어떤 뷰포트로 찍을지 등을 결정한다.

특정 Story에 대해 비주얼 테스트 동작을 제어할 수도 있다.

tsx
export const AnimatedComponent: Story = {
  args: { label: "Hello" },
  parameters: {
    chromatic: {
      // 애니메이션 완료 후 캡처하기 위해 딜레이 설정
      delay: 500,
      // 여러 뷰포트에서 캡처
      viewports: [320, 768, 1200],
      // 이 Story는 비주얼 테스트에서 제외
      // disableSnapshot: true,
    },
  },
};

delay로 애니메이션이 끝난 후 캡처하게 할 수 있고, viewports로 여러 화면 크기에서의 렌더링을 동시에 테스트할 수 있다. 비주얼 테스트가 불필요한 Story는 disableSnapshot: true로 제외한다.

TurboSnap — 변경된 것만 테스트

모든 Story를 매번 캡처하면 시간과 비용이 많이 든다. Chromatic의 TurboSnap은 Git diff를 분석해서 실제로 변경된 컴포넌트와 그 컴포넌트에 의존하는 Story만 선별적으로 캡처한다.

bash
npx chromatic --only-changed

예를 들어 Button.tsx만 수정했다면, Button을 사용하는 Story만 다시 캡처하고 나머지는 이전 스냅샷을 재사용한다. 대규모 프로젝트에서 비주얼 테스트 시간을 수십 분에서 수 분으로 줄일 수 있다.

설정 파일 구조

Storybook 9 프로젝트의 핵심 설정 파일은 .storybook/main.ts.storybook/preview.ts 두 개다.

ts
// .storybook/main.ts — 빌드/런타임 설정
import type { StorybookConfig } from "@storybook/nextjs-vite";

const config: StorybookConfig = {
  // Story 파일 위치 (glob 패턴)
  stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],

  // 애드온 목록
  addons: [
    "@chromatic-com/storybook",
    "@storybook/addon-docs",
    "@storybook/addon-a11y",
    "@storybook/addon-vitest",
  ],

  // 프레임워크 설정
  framework: {
    name: "@storybook/nextjs-vite",
    options: {},
  },

  // 정적 파일 디렉토리
  staticDirs: ["../public"],
};
export default config;

stories는 Storybook이 스캔할 파일 패턴이다. addons에 나열된 애드온이 순서대로 로드된다. framework은 사용하는 프레임워크(React, Vue, Svelte 등)와 빌드 도구(Vite, Webpack)를 지정한다. staticDirs는 이미지나 폰트 등 정적 파일을 서빙할 디렉토리다.

ts
// .storybook/preview.ts — 렌더링 설정
import type { Preview } from "@storybook/react";

const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
  // 전역 데코레이터 — 모든 Story를 감싸는 래퍼
  decorators: [
    (Story) => (
      <ThemeProvider>
        <Story />
      </ThemeProvider>
    ),
  ],
};
export default preview;

preview.ts는 Story가 렌더링되는 방식을 제어한다. decorators로 모든 Story에 Provider를 감쌀 수 있고, parameters로 기본 설정을 지정한다.

Storybook 배포

Storybook은 정적 사이트로 빌드할 수 있어서 어디든 배포 가능하다.

bash
# 정적 빌드
npx storybook build

# 결과물은 storybook-static/ 디렉토리에 생성됨
# Vercel, Netlify, S3 등 어디든 배포 가능

빌드된 Storybook을 배포하면 디자이너, PM 등 비개발자도 컴포넌트를 직접 확인할 수 있다. PR마다 프리뷰 URL을 생성해서 코드 리뷰에 활용하는 것도 일반적인 패턴이다.

Storybook 8과의 주요 차이 정리

항목Storybook 8Storybook 9
설치 크기기준48% 감소
테스트 실행Story별 개별 실행Vitest로 전체 일괄 실행
접근성 테스트애드온 설치 후 수동통합, 전체 자동 검사
Next.js 빌드Webpack 기반Vite 기반 옵션 추가
Story globals미지원Story 수준에서 설정 가능
태그 시스템제한적사이드바 필터링, 배지 지원
Story 생성수동 작성만UI에서 자동 생성 가능

Storybook 9의 핵심은 "Story를 작성하면 테스트가 따라온다"는 것이다. 이전에는 Story는 문서화 도구에 가까웠고, 테스트는 별도로 작성해야 했다. 이제는 Story 자체가 인터랙션 테스트, 접근성 테스트, 비주얼 테스트의 기반이 된다. 하나의 Story가 문서이자 테스트 케이스이자 비주얼 베이스라인 역할을 동시에 수행한다.

정리

  • Story 하나가 문서, 인터랙션 테스트, 접근성 검사, 비주얼 베이스라인을 동시에 담당한다. 별도 테스트 파일 없이 play 함수와 globals만으로 검증 범위를 넓힐 수 있다.
  • Chromatic의 TurboSnap은 Git diff 기반으로 변경된 컴포넌트만 선별 캡처하므로, Story가 수백 개여도 CI 비용과 시간을 통제할 수 있다.
  • Storybook 9의 Vitest 통합과 nextjs-vite 프레임워크 덕분에 Vite 생태계와 자연스럽게 맞물리고, Watch 모드에서 Story 수정 → 테스트 재실행이 즉각적이다.

관련 문서