본문 바로가기
(Frontend) 프론트엔드

(Storybook) 컴포넌트 주도 개발(CDD) - 개념부터 실전 테스트까지 (React + TypeScript + Vite)

by 공부가싫다가도좋아 2026. 3. 6.
반응형

포스팅 목차

  1. CDD란 무엇인가?
  2. Storybook이란?
  3. Storybook 설치 및 기본 세팅 (Vite + React + TypeScript)
  4. Story 작성법 — Args, Controls 상세 설명
  5. 실전 컴포넌트 Story 작성 (Button, Input 예시)
  6. Interaction Test / Play function 사용법
  7. 공부하면서 생겼던 의문점
  8. 참고 자료

1. CDD란 무엇인가?

CDDComponent-Driven Development) 란 UI를 가장 작은 단위인 컴포넌트부터 만들어 올라가는 개발 방법론입니다.

쉽게 말하면, 레고 블록을 조립하듯 작은 컴포넌트를 먼저 만들고 → 그것들을 조합해서 페이지를 완성하는 방식이에요.

기존 개발 방식과 CDD를 비교하면 아래와 같습니다.

구분 기존 방식 CDD
개발 순서 페이지 → 컴포넌트 분리 컴포넌트 → 페이지 조립
테스트 시점 페이지 완성 후 컴포넌트 단위로 즉시
재사용성 낮음 높음
디자이너 협업 어려움 쉬움 (Storybook UI로 공유)

CDD의 핵심 철학은 "컴포넌트는 독립적으로 개발되고, 독립적으로 테스트되어야 한다" 는 것입니다.


2. Storybook이란?

Storybook은 CDD를 실천할 수 있도록 도와주는 UI 컴포넌트 개발 도구입니다.

컴포넌트를 실제 앱과 분리된 독립된 환경에서 개발하고, 다양한 상태(state)를 시각적으로 확인할 수 있게 해줍니다.

Storybook의 주요 기능

  • 컴포넌트의 다양한 상태를 "Story"로 관리
  • Controls 패널로 props를 실시간으로 변경하며 확인
  • Interaction Test로 사용자 행동 시뮬레이션
  • 디자이너/기획자와 컴포넌트 상태 공유 가능

💡 한 줄 요약 : Storybook = 컴포넌트들의 쇼룸(전시관)


3. Storybook 설치 및 기본 세팅 (Vite + React + TypeScript)

설치 방법

기존 Vite + React + TypeScript 프로젝트에 Storybook을 추가합니다.

npx storybook@latest init

위 명령어 하나로 프로젝트를 자동으로 감지해서 필요한 설정을 잡아줍니다.
(Vite 프로젝트일 경우 자동으로 인식합니다)

설치가 완료되면 아래 파일 및 폴더들이 생성됩니다.

your-project/
├── .storybook/
│   ├── main.ts        ← Storybook 핵심 설정
│   └── preview.ts     ← 전역 데코레이터, 파라미터 설정
└── src/
    └── stories/       ← 예제 Story 파일들 (삭제해도 됨)

실행 방법

npm run storybook

http://localhost:6006 에서 Storybook UI를 아래 사진과 같이 확인할 수 있습니다.

스토리북 실행 시 화면

.storybook/main.ts 기본 구조

import type { StorybookConfig } from "@storybook/react-vite";

const config: StorybookConfig = {
  // Story 파일 위치 패턴 지정
  // src 폴더 하위의 모든 .stories.ts(x) 파일을 Story로 인식
  stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],

  addons: [
    "@storybook/addon-onboarding",
    "@storybook/addon-essentials", // Controls, Actions, Docs 등 핵심 애드온 묶음
    "@storybook/addon-interactions", // Interaction Test를 위한 애드온
  ],

  // Vite 기반 빌더 사용
  framework: {
    name: "@storybook/react-vite",
    options: {},
  },
};

export default config;

4. Story 작성법 — Args, Controls 상세 설명

Story란?

Story는 컴포넌트의 특정 상태 하나를 의미합니다.

예를 들어 Button 컴포넌트라면 아래처럼 여러 개의 Story를 가질 수 있어요.

  • Primary → variant="primary"인 버튼 상태
  • Disabled → disabled 상태인 버튼
  • Loading → 로딩 중인 버튼

CSF(Component Story Format) 구조

Storybook은 CSF 3.0 형식으로 Story를 작성합니다.

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

// Meta: 이 파일이 어떤 컴포넌트의 Story인지 정의
const meta: Meta<typeof Button> = {
  title: "Components/Button", // Storybook 사이드바에서의 경로
  component: Button,          // 대상 컴포넌트
  tags: ["autodocs"],         // 자동으로 Docs 페이지 생성
};

export default meta;

// StoryObj: 개별 Story 타입
type Story = StoryObj<typeof Button>;

// Story 하나 = 컴포넌트의 상태 하나
export const Primary: Story = {
  args: {
    label: "버튼",
    variant: "primary",
  },
};

Args란?

Args 는 Story에 전달되는 props 값입니다.

args를 정의하면 Storybook의 Controls 패널에서 실시간으로 값을 바꿔볼 수 있어요.

export const Primary: Story = {
  args: {
    // 여기에 정의된 값들이 Controls 패널에 나타납니다
    label: "클릭하세요",
    variant: "primary",
    size: "medium",
    disabled: false,
  },
};

Controls란?

Controls는 args로 정의된 props를 UI에서 실시간으로 변경할 수 있게 해주는 패널입니다.

별도 설정 없이도 TypeScript의 타입 정의를 읽어서 자동으로 적절한 컨트롤 UI를 생성해줍니다.

타입 Controls UI
string 텍스트 입력창
boolean 토글 스위치
number 숫자 입력창
union (A | B | C) 드롭다운 선택

컨트롤 타입을 수동으로 지정하고 싶을 때는 argTypes를 사용합니다.

const meta: Meta<typeof Button> = {
  title: "Components/Button",
  component: Button,
  argTypes: {
    variant: {
      // 드롭다운으로 선택하게 강제 지정
      control: "select",
      options: ["primary", "secondary", "danger"],
      description: "버튼의 시각적 스타일을 결정합니다",
    },
    size: {
      control: "radio",
      options: ["small", "medium", "large"],
    },
    onClick: {
      // 함수는 Controls에서 편집 불가 처리
      action: "clicked",
    },
  },
};

빨간 네모 박스에서 설정한 args 값들을 변경할 수 있음


5. 실전 컴포넌트 Story 작성 (Button, Input 예시)

Button 컴포넌트

Button.tsx

import React from "react";

type ButtonVariant = "primary" | "secondary" | "danger";
type ButtonSize = "small" | "medium" | "large";

interface ButtonProps {
  label: string;
  variant?: ButtonVariant;
  size?: ButtonSize;
  disabled?: boolean;
  isLoading?: boolean;
  onClick?: () => void;
}

// variant별 배경색/글자색 스타일
const variantStyles: Record<ButtonVariant, React.CSSProperties> = {
  primary: { backgroundColor: "#3b82f6", color: "#ffffff" },
  secondary: { backgroundColor: "#e5e7eb", color: "#1f2937" },
  danger: { backgroundColor: "#ef4444", color: "#ffffff" },
};

// size별 패딩/폰트 크기 스타일
const sizeStyles: Record<ButtonSize, React.CSSProperties> = {
  small: { padding: "4px 12px", fontSize: "12px" },
  medium: { padding: "8px 16px", fontSize: "14px" },
  large: { padding: "12px 24px", fontSize: "16px" },
};

export const Button = ({
  label,
  variant = "primary",
  size = "medium",
  disabled = false,
  isLoading = false,
  onClick,
}: ButtonProps) => {
  const style: React.CSSProperties = {
    // 공통 스타일
    borderRadius: "6px",
    fontWeight: "600",
    border: "none",
    cursor: disabled || isLoading ? "not-allowed" : "pointer",
    opacity: disabled || isLoading ? 0.5 : 1,
    // variant, size 스타일 병합
    ...variantStyles[variant],
    ...sizeStyles[size],
  };

  return (
    <button style={style} disabled={disabled || isLoading} onClick={onClick}>
      {isLoading ? "로딩 중..." : label}
    </button>
  );
};

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", "danger"],
      description: "버튼의 시각적 스타일",
    },
    size: {
      control: "radio",
      options: ["small", "medium", "large"],
      description: "버튼 크기",
    },
    onClick: { action: "clicked" }, // Actions 패널에서 클릭 이벤트 확인
  },
};

export default meta;
type Story = StoryObj<typeof Button>;

// 기본 상태
export const Primary: Story = {
  args: {
    label: "확인",
    variant: "primary",
    size: "medium",
  },
};

// 비활성화 상태
export const Disabled: Story = {
  args: {
    label: "비활성화",
    variant: "primary",
    disabled: true,
  },
};

// 로딩 상태
export const Loading: Story = {
  args: {
    label: "저장하기",
    isLoading: true,
  },
};

// 위험 액션 버튼 (삭제 등)
export const Danger: Story = {
  args: {
    label: "삭제하기",
    variant: "danger",
    size: "medium",
  },
};

Input 컴포넌트

Input.tsx

import React from "react";

interface InputProps {
  label: string;
  placeholder?: string;
  value?: string;
  errorMessage?: string;
  disabled?: boolean;
  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

export const Input = ({
  label,
  placeholder,
  value,
  errorMessage,
  disabled = false,
  onChange,
}: InputProps) => {
  const wrapperStyle: React.CSSProperties = {
    display: "flex",
    flexDirection: "column",
    gap: "4px",
    width: "100%",
  };

  const labelStyle: React.CSSProperties = {
    fontSize: "13px",
    fontWeight: "500",
    color: "#374151",
  };

  const inputStyle: React.CSSProperties = {
    border: `1px solid ${errorMessage ? "#ef4444" : "#d1d5db"}`,
    borderRadius: "6px",
    padding: "8px 12px",
    fontSize: "13px",
    outline: "none",
    backgroundColor: disabled ? "#f3f4f6" : "#ffffff",
    cursor: disabled ? "not-allowed" : "text",
  };

  const errorStyle: React.CSSProperties = {
    fontSize: "11px",
    color: "#ef4444",
  };

  return (
    <div style={wrapperStyle}>
      <label style={labelStyle}>{label}</label>
      <input
        style={inputStyle}
        placeholder={placeholder}
        value={value}
        disabled={disabled}
        onChange={onChange}
      />
      {/* 에러 메시지가 있을 때만 표시 */}
      {errorMessage && <span style={errorStyle}>{errorMessage}</span>}
    </div>
  );
};

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: "이메일",
    placeholder: "이메일을 입력해주세요",
  },
};

// 에러 상태
export const WithError: Story = {
  args: {
    label: "이메일",
    placeholder: "이메일을 입력해주세요",
    value: "invalid-email",
    errorMessage: "올바른 이메일 형식이 아닙니다",
  },
};

// 비활성화 상태
export const Disabled: Story = {
  args: {
    label: "이메일",
    placeholder: "수정할 수 없습니다",
    disabled: true,
  },
};

6. Interaction Test / Play function 사용법

Interaction Test란?

Interaction Test는 사용자의 행동(클릭, 타이핑 등)을 자동으로 시뮬레이션하고 그 결과를 검증하는 기능입니다.

play 함수 안에 사용자 행동 시나리오를 작성하면 Storybook이 자동으로 실행해줍니다.

필요한 라이브러리

npm install --save-dev @storybook/test @storybook/addon-interactions
라이브러리 역할
@storybook/test userEvent, expect 등 테스트 유틸 제공
@storybook/addon-interactions Storybook UI에서 테스트 결과 시각화

play function 기본 구조

import type { Meta, StoryObj } from "@storybook/react";
import { expect, userEvent, within } from "@storybook/test";

export const SomeStory: Story = {
  play: async ({ canvasElement }) => {
    // canvasElement: 현재 Story가 렌더링된 DOM 요소
    const canvas = within(canvasElement);

    // 1. 요소 찾기
    const button = canvas.getByRole("button", { name: "확인" });

    // 2. 사용자 행동 시뮬레이션
    await userEvent.click(button);

    // 3. 결과 검증
    await expect(button).toBeDisabled();
  },
};

실전 예시 — 버튼 클릭 후 상태 변화 테스트

로그인 폼에서 버튼 클릭 → 로딩 상태로 전환되는 시나리오를 테스트합니다.

LoginForm.tsx

import React, { useState } from "react";
import { Button } from "../Button/Button";
import { Input } from "../Input/Input";

interface LoginFormProps {
  onSubmit: (email: string, password: string) => Promise<void>;
}

export const LoginForm = ({ onSubmit }: LoginFormProps) => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [isLoading, setIsLoading] = useState(false);

  const handleSubmit = async () => {
    setIsLoading(true);
    await onSubmit(email, password);
    setIsLoading(false);
  };

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        gap: "16px",
        width: "320px",
        padding: "24px",
        border: "1px solid #e5e7eb",
        borderRadius: "8px",
      }}
    >
      <Input
        label="이메일"
        placeholder="이메일을 입력해주세요"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <Input
        label="비밀번호"
        placeholder="비밀번호를 입력해주세요"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <Button label="로그인" isLoading={isLoading} onClick={handleSubmit} />
    </div>
  );
};

LoginForm.stories.tsx

import type { Meta, StoryObj } from "@storybook/react";
import { expect, userEvent, within } from "@storybook/test";
import { LoginForm } from "./LoginForm";

const meta: Meta<typeof LoginForm> = {
  title: "Components/LoginForm",
  component: LoginForm,
  tags: ["autodocs"],
};

export default meta;
type Story = StoryObj<typeof LoginForm>;

// 기본 상태
export const Default: Story = {
  args: {
    // onSubmit을 즉시 resolve하는 mock 함수
    onSubmit: async () => {},
  },
};

// Interaction Test: 이메일/비밀번호 입력 후 로그인 버튼 클릭
export const FilledAndSubmit: Story = {
  args: {
    // 1초 후 resolve되는 mock으로 로딩 상태를 확인할 수 있게 함
    onSubmit: () => new Promise((resolve) => setTimeout(resolve, 1000)),
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // 1. 이메일 입력
    const emailInput = canvas.getByPlaceholderText("이메일을 입력해주세요");
    await userEvent.type(emailInput, "test@example.com");

    // 2. 비밀번호 입력
    const passwordInput = canvas.getByPlaceholderText("비밀번호를 입력해주세요");
    await userEvent.type(passwordInput, "password123");

    // 3. 로그인 버튼 클릭
    const loginButton = canvas.getByRole("button", { name: "로그인" });
    await userEvent.click(loginButton);

    // 4. 로딩 상태 검증 — 버튼이 "로딩 중..." 텍스트로 바뀌어야 함
    await expect(canvas.getByRole("button", { name: "로딩 중..." })).toBeInTheDocument();
  },
};

// Interaction Test: 빈 폼으로 제출 시도
export const EmptySubmit: Story = {
  args: {
    onSubmit: async () => {},
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // 아무것도 입력하지 않고 버튼 클릭
    const loginButton = canvas.getByRole("button", { name: "로그인" });
    await userEvent.click(loginButton);

    // 버튼이 여전히 활성화 상태인지 확인 (에러 처리는 상위 컴포넌트 담당)
    await expect(loginButton).not.toBeDisabled();
  },
};

play function에서 자주 쓰는 유틸 정리

import { expect, userEvent, within, waitFor } from "@storybook/test";

// 요소 찾기
canvas.getByRole("button", { name: "텍스트" }); // role + 접근 가능한 이름으로 찾기
canvas.getByPlaceholderText("placeholder 텍스트"); // placeholder로 찾기
canvas.getByLabelText("label 텍스트");             // label로 연결된 input 찾기
canvas.getByText("텍스트 내용");                   // 텍스트 내용으로 찾기

// 사용자 행동
await userEvent.click(element);            // 클릭
await userEvent.type(element, "텍스트");   // 타이핑
await userEvent.clear(element);            // 입력값 지우기
await userEvent.hover(element);            // 마우스 올리기
await userEvent.tab();                     // Tab 키 누르기

// 검증
await expect(element).toBeInTheDocument();  // 요소가 DOM에 존재하는지
await expect(element).toBeDisabled();       // 비활성화 상태인지
await expect(element).toBeVisible();        // 화면에 보이는지
await expect(element).toHaveValue("값");   // 특정 값을 가지는지

// 비동기 상태 기다리기
await waitFor(() => {
  expect(canvas.getByText("완료")).toBeInTheDocument();
});

7. 공부하면서 생겼던 의문점

Q. Story 파일은 어디에 두는 게 좋을까요?

두 가지 방식이 있어요.

// 방식 1: 컴포넌트 옆에 두기 (추천)
src/components/Button/
├── Button.tsx
├── Button.stories.tsx
└── Button.test.tsx

// 방식 2: stories 폴더에 모아두기
src/stories/
├── Button.stories.tsx
└── Input.stories.tsx

저는 개인적으로 컴포넌트와 같은 폴더에 두는 방식 1을 추천합니다.
이렇게 위치 시킬 시 컴포넌트 수정 시 Story도 함께 관리하기 쉽습니다.


Q. Jest, Storybook Interaction Test, Playwright 중 뭘 써야 하나요?

세 가지는 목적이 다릅니다.

구분 Jest Storybook Interaction Test Playwright
목적 로직 단위 테스트 UI 행동 시각적 검증 E2E 전체 흐름 테스트
실행 환경 터미널 (Node.js) 브라우저 (Storybook UI) 실제 브라우저 자동화
테스트 범위 함수/훅/유틸 단위 컴포넌트 단위 페이지 전체 시나리오
서버 필요 여부 불필요 불필요 (MSW로 모킹) 필요 (실제 앱 + 백엔드)
속도 빠름 중간 느림

셋을 함께 쓰는 것이 가장 이상적이지만, 도입 우선순위를 매기자면 Jest → Storybook Interaction Test → Playwright 순서를 추천합니다.

  • Jest → 부품 하나하나 품질 검사
  • Storybook Interaction Test → 조립된 부품이 제대로 작동하는지 검사
  • Playwright → 완성된 제품을 실제 사용자처럼 처음부터 끝까지 테스트

Q. MobX store를 사용하는 컴포넌트는 어떻게 Story를 작성하나요?

Decorator를 사용해서 Story에 MobX store를 주입할 수 있습니다.

import { Meta, StoryObj } from "@storybook/react";
import { MyComponent } from "./MyComponent";
import { MyStore } from "../../stores/MyStore";

const store = new MyStore();

const meta: Meta<typeof MyComponent> = {
  title: "Components/MyComponent",
  component: MyComponent,
  decorators: [
    (Story) => (
      // Context나 props로 store를 전달하는 방식으로 감싸줍니다
      <div>
        <Story />
      </div>
    ),
  ],
};

MobX를 Context로 관리하고 있다면 Provider를 Decorator에서 감싸주는 방식으로 처리하면 됩니다~!

 


8. 참고 자료

반응형

댓글