포스팅 목차
- CDD란 무엇인가?
- Storybook이란?
- Storybook 설치 및 기본 세팅 (Vite + React + TypeScript)
- Story 작성법 — Args, Controls 상세 설명
- 실전 컴포넌트 Story 작성 (Button, Input 예시)
- Interaction Test / Play function 사용법
- 공부하면서 생겼던 의문점
- 참고 자료
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",
},
},
};

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. 참고 자료
- Storybook 공식 문서: https://storybook.js.org/docs
- Storybook Interaction Testing: https://storybook.js.org/docs/writing-tests/interaction-testing
- Component Story Format (CSF): https://storybook.js.org/docs/api/csf
- @storybook/test API: https://storybook.js.org/docs/writing-tests/test-utilities
'(Frontend) 프론트엔드' 카테고리의 다른 글
| 문과생 개발자 면접 - 프론트엔드 개발자 공통 질문 요약 (0) | 2021.10.01 |
|---|---|
| (VsCode)Visual Studio Code 저장할 때 자동 코드정렬/자동 코드정렬 prettierrc 사용하기 (0) | 2021.08.17 |
| (웹) 밑줄치기 효과/ hover시 밑줄 쳐지는 효과 구현하기(매우 간단) (0) | 2021.07.30 |
| VS Code에서 html, css, 자바스크립트 정렬 단축키/ 단축키 확장프로그램 사용법 (0) | 2021.07.23 |
| (웹)DOM 테이블 추가,삭제/사용자가 원하는 만큼 테이블 추가 삭제 예제(결과화면 有) (0) | 2021.05.11 |
댓글