logoRawon_Log
홈블로그소개

Built with Next.js, Bun, Tailwind CSS and Shadcn/UI

Web

StoryBook을 활용한 디자인 시스템 구축

Rawon
2025년 11월 2일
목차
📚 Storybook으로 디자인 시스템 구축
🎯 1. 디자인 시스템이란?
Storybook의 핵심 개념 📦
⚙️ 2. 프로젝트 환경 설정
Vite 프로젝트 생성 🚀
Storybook 10 설치 및 설정 📥
Storybook 설정 파일 이해하기
Vite 빌더 자동 설정 🔧
Tailwind CSS 4 설정 🎨
폰트 설정 🔤
📝 3. 컴포넌트 개발과 스토리 작성
Component Story Format (CSF) 이해하기
Meta 객체의 주요 속성
argTypes의 control 타입
ErrorMessage 컴포넌트
IconButton 컴포넌트와 SVG 관리
Atomic Design으로 DefaultTextField 만들기
Cumulative Layout Shift(CLS) 고려하기
Story Decorator 활용하기
TypeScript Record 타입으로 테마 관리하기
Generic Type으로 타입 안전성 확보하기

목차

📚 Storybook으로 디자인 시스템 구축
🎯 1. 디자인 시스템이란?
Storybook의 핵심 개념 📦
⚙️ 2. 프로젝트 환경 설정
Vite 프로젝트 생성 🚀
Storybook 10 설치 및 설정 📥
Storybook 설정 파일 이해하기
Vite 빌더 자동 설정 🔧
Tailwind CSS 4 설정 🎨
폰트 설정 🔤
📝 3. 컴포넌트 개발과 스토리 작성
Component Story Format (CSF) 이해하기
Meta 객체의 주요 속성
argTypes의 control 타입
ErrorMessage 컴포넌트
IconButton 컴포넌트와 SVG 관리
Atomic Design으로 DefaultTextField 만들기
Cumulative Layout Shift(CLS) 고려하기
Story Decorator 활용하기
TypeScript Record 타입으로 테마 관리하기
Generic Type으로 타입 안전성 확보하기

📚 Storybook으로 디자인 시스템 구축

🎯 1. 디자인 시스템이란?

디자인 시스템이란? 🎨

제품의 디자인과 개발을 안내하는 광범위한 지침, 표준, 그리고 실천사항을 모아놓은 것입니다. 쉽게 설명하자면, 우리 제품의 디자인에 대한 규칙이라고 할 수 있습니다.

디자인 시스템의 장점 ✨

  • 서비스 UI에 일관성을 부여합니다
  • 코드 및 디자인의 유지보수가 유리해집니다
  • 기획 ↔ 디자이너 ↔ 개발자 간의 커뮤니케이션 비용을 개선할 수 있습니다

대표적인 디자인 시스템 예시 📖

  • Material Design
  • Human Interface Guidelines

Storybook의 핵심 개념 📦

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

Story의 개념 💡

Storybook에서 가장 중요한 개념은 바로 story입니다. Story는 UI 컴포넌트의 특정 상태(state)를 의미합니다.

예를 들어 <button> 컴포넌트의 경우:

  • disabled 상태: 비활성화된 버튼
  • enabled 상태: 활성화된 버튼
  • primary 테마: 주요 액션 버튼
  • secondary 테마: 보조 액션 버튼

이렇게 하나의 컴포넌트가 가질 수 있는 다양한 상태들을 각각의 story로 작성하고, Storybook UI에서 시각적으로 확인할 수 있습니다.


Storybook이 디자인 시스템에서 하는 역할 🎯

  1. 디자인 가이드 준수 확인
    • 개발한 컴포넌트가 디자인 가이드에 맞는지 즉시 확인할 수 있습니다
    • 컴포넌트의 레이아웃, 색상, 타이포그래피 등을 일관성 있게 관리할 수 있습니다
  2. 문서화 및 공유
    • 디자인 토큰, 컴포넌트, 패턴 등을 자동으로 문서화합니다
    • 팀 내에서 컴포넌트를 쉽게 공유하고 재사용할 수 있습니다
  3. 협업 강화
    • 프론트엔드 개발자들 간 소통이 수월해집니다
    • 디자이너가 실제 동작하는 컴포넌트를 직접 확인할 수 있어 협업이 원활해집니다

⚙️ 2. 프로젝트 환경 설정

Vite 프로젝트 생성 🚀

먼저 Vite를 사용하여 프로젝트를 생성합니다:

bash
npm create vite@latest my-design-system -- --template react-ts
cd my-design-system
npm install

Storybook 10 설치 및 설정 📥

⚠️ Storybook 10의 주요 변경사항

Storybook 10부터는 ESM-only로 변경되었습니다. 이는 다음을 의미합니다:

필수 요구사항:

  • Node.js 버전: 20.16, 22.19, 24 이상
  • 모든 설정 파일(.storybook/main.js|ts, preview.js|ts 등)이 유효한 ESM 형식이어야 합니다
  • CommonJS 문법(require, module.exports)은 더 이상 사용할 수 없습니다

ESM-only 전환의 장점:

  • 설치 크기가 29% 감소했습니다
  • 디버깅을 위해 코드가 minify되지 않은 상태로 제공됩니다
  • 전체 JavaScript 생태계가 ESM으로 수렴하는 추세에 맞춰 개발할 수 있습니다

Storybook 설치하기:

bash
npx storybook@latest init


이 명령어를 실행하면:
1. Storybook이 프로젝트를 자동으로 감지합니다
2. 필요한 패키지들을 설치합니다
3. `.storybook` 디렉토리와 설정 파일들을 생성합니다
4. 예제 스토리 파일들(Button, Header, Page)을 생성합니다
5. 설치가 완료되면 자동으로 Storybook이 실행됩니다

**프로젝트 구조:**

my-design-system/
├── .storybook/
│   ├── main.ts          
# Storybook 메인 설정

│   └── preview.ts       
# 전역 스토리 설정

├── src/
│   └── stories/         
# 예제 스토리들

└── package.json

Storybook 설정 파일 이해하기

.storybook/main.ts - 메인 설정 파일:

typescript
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-interactions',  
// 인터랙션 테스트

  ],
  
  
// 프레임워크 설정 (Vite 빌더 자동 포함)

  framework: {
    name: '@storybook/react-vite',
    options: {},
  },
};

export default config;

주요 설정 옵션:

  • stories: 스토리 파일들의 위치를 glob 패턴으로 지정합니다
  • addons: 사용할 Storybook 애드온들을 나열합니다
  • framework: 사용 중인 프레임워크와 빌더를 지정합니다

.storybook/preview.ts - 전역 스토리 설정:

typescript
import type { Preview } from '@storybook/react';

const preview: Preview = {
  parameters: {
    
// 모든 스토리에 적용될 설정

    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
};

export default preview;

Vite 빌더 자동 설정 🔧

ℹ️ Storybook 7.0 이후 변경사항

Storybook 7.0부터는 @storybook/react-vite와 같은 프레임워크를 사용하면 Vite 빌더가 자동으로 설정됩니다.

이전 버전(~6.5)과의 차이:

  • 이전에는 .storybook/main.ts에 core.builder 설정을 명시적으로 추가해야 했습니다
  • 현재는 framework 이름에 vite가 포함되어 있으면 자동으로 Vite 빌더를 사용합니다
  • 별도의 @storybook/builder-vite 패키지 설치가 필요 없습니다

Vite 설정 파일 자동 인식:

Storybook은 프로젝트 루트의 vite.config.ts 파일을 자동으로 읽어서 적용합니다.

typescript
import type { StorybookConfig } from '@storybook/react-vite';
import { mergeConfig } from 'vite';

const config: StorybookConfig = {
  framework: '@storybook/react-vite',
  
  async viteFinal(config) {
    
// Vite 설정 커스터마이징

    return mergeConfig(config, {
      resolve: {
        alias: {
          '@components': '/src/components',
        },
      },
    });
  },
};

export default config;

Tailwind CSS 4 설정 🎨

Tailwind CSS 4를 설치하고 설정합니다:

bash
npm install -D tailwindcss@next @tailwindcss/vite@next

vite.config.ts 수정:

typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  plugins: [react(), tailwindcss()],
});

디자인 시스템 색상 설정 - src/index.css****:

Tailwind CSS 4부터는 tailwind.config.ts 파일이 더 이상 생성되지 않습니다. 대신 CSS 파일에서 @theme 디렉티브를 사용하여 디자인 토큰을 정의합니다:

css
@import "tailwindcss";

@theme {
  
/* 색상 정의 */

  --color-primary: #1d2745;
  --color-secondary: #1de5d4;
  --color-tertiary: #f52c50;
  --color-white: #ffffff;
  --color-mono-100: #f1f1f1;
  --color-mono-200: #bebebe;
  --color-mono-300: #d6d7d9;
  --color-error: #d01e1e;
  
  
/* 버튼 radius */

  --radius-button-default: 8px;
}

body {
  margin: 0;
  font-family: "Noto Sans KR", sans-serif;
}

사용 예시:

typescript
// Tailwind 클래스로 사용

<div className="bg-primary text-white">
  <p className="text-error">에러 메시지</p>
</div>

Tailwind CSS 4의 장점:

  • 설정 파일 없이 CSS만으로 테마 관리가 가능합니다
  • CSS 변수를 직접 사용하여 더 유연한 스타일링이 가능합니다
  • 빌드 속도가 더 빠릅니다
  • JavaScript 설정 파일의 복잡도가 줄어듭니다

폰트 설정 🔤

@fontsource를 사용하여 폰트를 설정합니다:

fontsource의 장점:

  • 다양한 폰트를 npm 패키지로 제공합니다
  • 폰트 파일을 직접 다운로드하는 것보다 번들 용량이 작습니다
  • 필요한 웨이트만 선택적으로 import할 수 있습니다
  • CDN 의존성이 없어 안정적입니다
bash
npm install @fontsource/noto-sans-kr

**src/main.tsx**에서 import:

typescript
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';


// 필요한 폰트 웨이트만 import

import "@fontsource/noto-sans-kr/400.css";
import "@fontsource/noto-sans-kr/700.css";

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);

Storybook에도 폰트 적용하기:

.storybook/preview.ts에도 동일하게 폰트를 import하면 Storybook UI에도 폰트가 적용됩니다:

typescript
import type { Preview } from '@storybook/react';
import '../src/index.css';
import "@fontsource/noto-sans-kr/400.css";
import "@fontsource/noto-sans-kr/700.css";

const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
};

export default preview;

📝 3. 컴포넌트 개발과 스토리 작성

Component Story Format (CSF) 이해하기

Storybook은 CSF(Component Story Format)라는 표준화된 형식을 사용합니다. CSF는 ES6 모듈 기반의 형식으로, 현재 CSF 3이 표준입니다.

CSF의 기본 구조:

typescript
import type { Meta, StoryObj } from '@storybook/react-vite';
import { ComponentName } from './ComponentName';


// 1. Meta 객체: 컴포넌트에 대한 메타데이터

const meta = {
  title: 'Category/ComponentName',  
// Storybook 사이드바 경로

  component: ComponentName,          
// 스토리의 대상 컴포넌트

  parameters: { 
/* ... */
 },         
// 스토리 표시 설정

  tags: ['autodocs'],                
// 자동 문서화 태그

  argTypes: { 
/* ... */
 },           
// Props 설정 및 설명

} satisfies Meta<typeof ComponentName>;

export default meta;


// 2. Story 타입 정의

type Story = StoryObj<typeof meta>;


// 3. 개별 스토리들

export const Default: Story = {
  args: { 
/* props */
 },
};

Meta 객체의 주요 속성

title - Storybook 사이드바 경로:

typescript
// 단일 경로

title: 'Button'  
// → Button// 중첩 경로 (권장)

title: 'Components/Button'  
// → Components > Button

title: 'Design System/Atoms/Button'  
// → Design System > Atoms > Button

parameters - 스토리 레이아웃 설정:

typescript
parameters: {
  layout: 'centered',  
// 'centered' | 'fullscreen' | 'padded'

}
  • centered: 캔버스 중앙에 컴포넌트 배치 (기본값, 가장 많이 사용)
  • fullscreen: 전체 화면으로 표시
  • padded: 패딩을 추가하여 표시

tags - 자동 문서화:

typescript
tags: ['autodocs']  
// 자동으로 문서 페이지 생성

argTypes - Props 설정 및 문서화 (⭐️ 중요 ⭐️)

argTypes는 컴포넌트의 props를 Controls 패널에서 조작할 수 있게 하고, 동시에 문서화하는 역할을 합니다.

typescript
argTypes: {
  
// 기본 구조

  propName: {
    control: 'control-type',      
// Controls 패널의 입력 타입

    description: '설명',           
// Props 설명

    defaultValue: 'default',      
// 기본값

    table: {                      
// 문서 테이블 설정

      type: { summary: 'string' },
      defaultValue: { summary: 'default' },
    },
  },
}

argTypes의 control 타입

텍스트 입력:

typescript
// 짧은 텍스트

control: 'text'


// 긴 텍스트 (textarea)

control: 'text'

불리언:

typescript
control: 'boolean'

숫자:

typescript
control: {
  type: 'number',
  min: 0,
  max: 100,
  step: 10,
}

선택:

typescript
// 드롭다운

control: {
  type: 'select',
  options: ['small', 'medium', 'large'],
}


// 라디오 버튼

control: {
  type: 'radio',
  options: ['primary', 'secondary'],
}

색상:

typescript
control: 'color'

날짜:

typescript
control: 'date'

객체/배열:

typescript
control: 'object'

---

### Label 컴포넌트 실습

**폴더 구조 정리:**

Storybook 스토리 파일과 실제 컴포넌트를 분리하여 관리하는 것이 좋습니다:
src/
├── components/          # 실제 컴포넌트
│   └── Label.tsx
└── stories/            # 스토리 파일
    └── Label.stories.ts

이렇게 분리하면:

  • 디자인 시스템을 npm 패키지로 배포할 때 스토리 파일을 제외할 수 있습니다
  • 컴포넌트와 문서를 독립적으로 관리할 수 있습니다

src/components/Label.tsx****:

typescript
interface ILabelProps {
  htmlFor: string;
  children: string;
}

export default function Label({ htmlFor, children }: ILabelProps) {
  return (
    <label className="text-sm text-primary font-bold" htmlFor={htmlFor}>
      {children}
    </label>
  );
}

src/stories/Label.stories.ts****:

typescript
import type { Meta, StoryObj } from "@storybook/react-vite";
import Label from "../components/Label";

const meta = {
  title: "Text/Label",
  component: Label,
  parameters: {
    layout: "centered",
  },
  tags: ["autodocs"],
  argTypes: {
    htmlFor: { 
      control: "text", 
      description: "연결할 input 요소의 id" 
    },
    children: { 
      control: "text", 
      description: "레이블에 표시될 텍스트" 
    },
  },
} satisfies Meta<typeof Label>;

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

export const Default: Story = {
  args: {
    htmlFor: "username",
    children: "이메일",
  },
};

ErrorMessage 컴포넌트

src/components/ErrorMessage.tsx****:

typescript
interface IErrorMessageProps {
  children: string;
}

export default function ErrorMessage({ children }: IErrorMessageProps) {
  return <p className="text-sm text-error font-bold">{children}</p>;
}

src/stories/ErrorMessage.stories.ts****:

typescript
import type { Meta, StoryObj } from "@storybook/react-vite";
import ErrorMessage from "../components/ErrorMessage";

const meta = {
  title: "Text/ErrorMessage",
  component: ErrorMessage,
  parameters: {
    layout: "centered",
  },
  tags: ["autodocs"],
  argTypes: {
    children: { 
      control: "text", 
      description: "표시할 에러 메시지" 
    },
  },
} satisfies Meta<typeof ErrorMessage>;

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

export const Default: Story = {
  args: {
    children: "이메일 형식이 올바르지 않습니다",
  },
};

IconButton 컴포넌트와 SVG 관리

⚠️ Storybook 10 이벤트 핸들러 필수화

Storybook 10부터는 이벤트 핸들러가 필수 옵션이 되었습니다. onClick, onChange 등의 이벤트 핸들러가 없으면 에러가 발생할 수 있습니다.

해결 방법: fn() 함수 사용

typescript
import { fn } from "@storybook/test";

const meta = {
  
// ...

  args: {
    onClick: fn(),  
// mock 이벤트 핸들러

  },
} satisfies Meta<typeof Component>;

IconButton 설계 고려사항:

작은 아이콘은 주로 SVG 형식을 사용합니다. IconButton에 아이콘을 전달하는 방법은 여러 가지가 있지만, iconPath를 props로 받는 방식이 관리하기 편합니다:

src/components/IconButton.tsx****:

typescript
interface IIconButtonProps {
  iconPath: string;
  alt: string;
  onClick: () => void;
}

export default function IconButton({ iconPath, alt, onClick }: IIconButtonProps) {
  return (
    <button onClick={onClick} className="p-2">
      <img src={iconPath} alt={alt} />
    </button>
  );
}

아이콘 파일 위치 전략:

아이콘 파일은 다음 3가지 위치에 둘 수 있습니다:

  1. public 디렉토리 (추천 ⭐)
public/
   └── icons/
       ├── ic-delete.svg
       ├── ic-edit.svg
       └── ic-close.svg
  • 장점: import 없이 경로만으로 사용 가능
  • 사용: /icons/ic-delete.svg
  • 권장: 용량이 작은 아이콘에 적합
  1. src/assets 디렉토리
src/
   └── assets/
       └── icons/
           └── ic-delete.svg
  • 단점: import 필요, 관리가 번거로움
  • 비추천
  1. 클라우드 스토리지

typescript

iconPath: "https://cdn.example.com/icons/ic-delete.svg"

  • 장점: 번들 크기 감소
  • 권장: 용량이 큰 아이콘이나 많은 아이콘 사용 시

src/stories/IconButton.stories.ts****:

typescript
import type { Meta, StoryObj } from "@storybook/react-vite";
import IconButton from "../components/IconButton";
import { fn } from "@storybook/test";

const meta = {
  title: "Buttons/IconButton",
  component: IconButton,
  parameters: {
    layout: "centered",
  },
  tags: ["autodocs"],
  argTypes: {
    alt: {
      control: "text",
      description: "아이콘의 대체 텍스트 (접근성)",
    },
    iconPath: { 
      control: "text", 
      description: "아이콘 이미지 경로" 
    },
    onClick: { 
      action: "clicked", 
      description: "클릭 이벤트 핸들러" 
    },
  },
  args: {
    onClick: fn(),  
// mock 함수

  },
} satisfies Meta<typeof IconButton>;

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

export const Default: Story = {
  args: {
    alt: "삭제",
    iconPath: "/icons/ic-delete.svg",
  },
};

Atomic Design으로 DefaultTextField 만들기

TextField는 여러 작은 컴포넌트들이 조합된 복합 컴포넌트입니다:

  • <input> 요소
  • <IconButton> (지우기 버튼)
  • <ErrorMessage> (에러 표시)

src/components/DefaultTextField.tsx****:

typescript
import { useState } from "react";
import ErrorMessage from "./ErrorMessage";
import IconButton from "./IconButton";

interface IDefaultTextFieldProps {
  id: string;
  placeholder: string;
  value: string;
  onChange: React.ChangeEventHandler<HTMLInputElement>;
  errorMessage: string;
  isError: boolean;
  iconPath: string;
  iconAlt: string;
  onIconClick: React.MouseEventHandler<HTMLButtonElement>;
}

export default function DefaultTextField({
  id,
  placeholder,
  value,
  onChange,
  errorMessage,
  isError,
  iconPath,
  iconAlt,
  onIconClick,
}: IDefaultTextFieldProps) {
  const [isFocused, setIsFocused] = useState(false);
  
  
// 상태에 따른 border 색상

  const borderColor = isFocused
    ? "border-secondary"      
// 포커스 시

    : !value
    ? "border-mono-300"       
// 빈 상태

    : "border-primary";       
// 입력된 상태


  return (
    <div>
      <div
        onFocus={() => setIsFocused(true)}
        onBlur={() => setIsFocused(false)}
        className={`
          flex items-center
          text-primary 
          border-b-2
          ${borderColor}
          transition-colors
        `}
      >
        <input
          id={id}
          className="flex-1 outline-none py-2"
          placeholder={placeholder}
          value={value}
          type="text"
          onChange={onChange}
        />
        {
/* 값이 있을 때만 삭제 버튼 표시 */
}
        {!!value && (
          <IconButton 
            onClick={onIconClick} 
            alt={iconAlt} 
            iconPath={iconPath} 
          />
        )}
      </div>
      {
/* 에러가 있을 때만 에러 메시지 표시 */
}
      {isError && <ErrorMessage>{errorMessage}</ErrorMessage>}
    </div>
  );
}

src/stories/DefaultTextField.stories.ts****:

typescript
import type { Meta, StoryObj } from "@storybook/react";
import DefaultTextField from "../components/DefaultTextField";
import { fn } from "@storybook/test";

const meta = {
  title: "TextFields/DefaultTextField",
  component: DefaultTextField,
  parameters: {
    layout: "centered",
  },
  tags: ["autodocs"],
  argTypes: {
    id: {
      control: "text",
      description: "input 요소의 id",
    },
    placeholder: {
      control: "text",
      description: "플레이스홀더 텍스트",
    },
    value: {
      control: "text",
      description: "현재 입력값",
    },
    errorMessage: {
      control: "text",
      description: "에러 메시지",
    },
    isError: {
      control: "boolean",
      description: "에러 상태 여부",
    },
    iconPath: {
      control: "text",
      description: "삭제 버튼 아이콘 경로",
    },
    iconAlt: {
      control: "text",
      description: "아이콘 대체 텍스트",
    },
    onChange: { 
      action: "changed", 
      description: "입력값 변경 이벤트" 
    },
    onIconClick: { 
      action: "clicked", 
      description: "삭제 버튼 클릭 이벤트" 
    },
  },
  args: {
    onChange: fn(),
    onIconClick: fn(),
  },
} satisfies Meta<typeof DefaultTextField>;

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


// 기본 상태

export const Default: Story = {
  args: {
    id: "email",
    placeholder: "이메일을 입력해주세요",
    value: "",
    errorMessage: "",
    isError: false,
    iconPath: "/icons/ic-delete.svg",
    iconAlt: "지우기",
  },
};


// 입력된 상태

export const Filled: Story = {
  args: {
    ...Default.args,
    value: "example@email.com",
  },
};


// 에러 상태

export const Error: Story = {
  args: {
    ...Default.args,
    value: "invalid-email",
    isError: true,
    errorMessage: "올바른 이메일 형식이 아닙니다",
  },
};

Cumulative Layout Shift(CLS) 고려하기

CLS란? 📊

CLS(Cumulative Layout Shift)는 Web Vitals의 하나로, 페이지 로딩 중 예기치 않은 레이아웃 변경을 측정하는 지표입니다.

문제 상황:

ErrorMessage가 나타나면서 아래 콘텐츠가 밀려나는 경우:

typescript
// ❌ 나쁜 예: CLS 발생

{isError && <ErrorMessage>{errorMessage}</ErrorMessage>}

해결 방법 1: 최소 높이 확보

typescript
// ✅ 좋은 예: 최소 높이 설정

<div className="min-h-[20px]">
  {isError && <ErrorMessage>{errorMessage}</ErrorMessage>}
</div>

해결 방법 2: 항상 공간 차지

typescript
// ✅ 좋은 예: 항상 공간 차지 (visibility로 제어)

<div className={isError ? 'visible' : 'invisible'}>
  <ErrorMessage>{errorMessage}</ErrorMessage>
</div>

CLS가 중요한 이유:

  • Google 검색 순위에 영향을 줍니다 (SEO)
  • 사용자 경험을 크게 해칩니다
  • 모바일에서 특히 중요합니다

권장 사항:

  • 에러 메시지, 로딩 인디케이터 등 동적으로 나타나는 요소는 미리 공간을 확보하세요
  • min-height나 aspect-ratio를 활용하세요
  • 이미지는 항상 width와 height 속성을 지정하세요

Story Decorator 활용하기

Decorator가 필요한 상황:

width: 100%를 가진 컴포넌트(NavigationBar, Footer 등)를 Storybook에서 표시할 때:

문제점:

  • 컴포넌트에 고정 width를 주면 재사용성이 떨어집니다
  • 반응형 디자인을 확인하기 어렵습니다

해결: Decorator 사용

Decorator는 스토리를 감싸는 래퍼 컴포넌트입니다. 마치 테스트의 wrapper처럼 동작합니다.

⚠️ 주의사항

Decorator를 사용하는 스토리 파일은 반드시 .tsx 확장자를 사용해야 합니다.


src/stories/NavigationBar.stories.tsx****:

typescript
import type { Meta, StoryObj } from "@storybook/react-vite";
import NavigationBar from "../components/NavigationBar";

const meta = {
  title: "Navigation/NavigationBar",
  component: NavigationBar,
  parameters: {
    layout: "centered",
  },
  tags: ["autodocs"],
  
// Decorator: 360px 컨테이너로 감싸기

  decorators: [
    (Story) => (
      <div style={{ width: "360px" }}>
        <Story />
      </div>
    ),
  ],
  argTypes: {
    title: {
      control: "text",
      description: "네비게이션 바 제목",
    },
    showBackButton: {
      control: "boolean",
      description: "뒤로가기 버튼 표시 여부",
    },
    showCloseButton: {
      control: "boolean",
      description: "닫기 버튼 표시 여부",
    },
    isDark: {
      control: "boolean",
      description: "다크 모드 여부",
    },
    onBackButtonClick: {
      action: "back-clicked",
      description: "뒤로가기 버튼 클릭 이벤트",
    },
    onCloseButtonClick: {
      action: "close-clicked",
      description: "닫기 버튼 클릭 이벤트",
    },
  },
} satisfies Meta<typeof NavigationBar>;

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

export const Default: Story = {
  args: {
    title: "페이지 제목",
    showBackButton: true,
    showCloseButton: true,
    isDark: false,
  },
};


// 다크 모드

export const Dark: Story = {
  args: {
    ...Default.args,
    isDark: true,
  },
};


// 뒤로가기만

export const BackOnly: Story = {
  args: {
    ...Default.args,
    showCloseButton: false,
  },
};

Decorator의 다른 활용 방법:

typescript
// 1. 다양한 너비 테스트

decorators: [
  (Story) => (
    <div>
      <div style={{ width: "360px", marginBottom: "20px" }}>
        <Story />
      </div>
      <div style={{ width: "768px" }}>
        <Story />
      </div>
    </div>
  ),
]


// 2. 테마 제공

decorators: [
  (Story) => (
    <ThemeProvider theme={darkTheme}>
      <Story />
    </ThemeProvider>
  ),
]


// 3. 라우터 제공

decorators: [
  (Story) => (
    <BrowserRouter>
      <Story />
    </BrowserRouter>
  ),
]

TypeScript Record 타입으로 테마 관리하기

다양한 테마를 가진 컴포넌트:

PrimaryButton은 여러 테마(dark, light, social, text)를 가집니다. 이럴 때 TypeScript의 Record 타입을 활용하면 타입 안전성을 확보하면서 관리할 수 있습니다.

src/components/PrimaryButton.tsx****:

typescript
// 1. 테마 타입 정의

type PrimaryButtonTheme = "dark" | "light" | "social" | "text";

interface IPrimaryButtonProps {
  theme: PrimaryButtonTheme;
  disabled: boolean;
  children: string;
  onClick: React.MouseEventHandler<HTMLButtonElement>;
}


// 2. 테마별 스타일 정의

const themeStyles: Record<PrimaryButtonTheme, string> = {
  dark: "bg-primary text-white",
  light: "bg-white text-primary",
  social: "bg-secondary text-white",
  text: "bg-transparent text-primary",
};

const disabledStyle = "disabled:bg-mono-100 disabled:text-mono-200";


// 3. 컴포넌트

export default function PrimaryButton({
  theme,
  children,
  onClick,
  disabled,
}: IPrimaryButtonProps) {
  return (
    <button
      className={`
        w-full 
        h-[59px] 
        rounded-[8px]
        font-bold
        transition-colors
        ${themeStyles[theme]}
        ${disabledStyle}
      `}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  );
}

Record 타입의 장점:

  • 테마 타입과 스타일 매핑이 일치함을 보장합니다
  • 새 테마 추가 시 컴파일 에러로 누락을 방지합니다
  • IDE 자동완성이 정확하게 동작합니다

src/stories/PrimaryButton.stories.tsx****:

typescript
import type { Meta, StoryObj } from "@storybook/react";
import PrimaryButton from "../components/PrimaryButton";
import { fn } from "@storybook/test";

const meta = {
  title: "Buttons/PrimaryButton",
  component: PrimaryButton,
  parameters: {
    layout: "centered",
  },
  decorators: [
    (Story) => (
      <div style={{ width: "360px" }}>
        <Story />
      </div>
    ),
  ],
  tags: ["autodocs"],
  argTypes: {
    theme: {
      control: {
        type: "select",
        options: ["dark", "light", "social", "text"],
      },
      description: "버튼 테마",
    },
    children: {
      control: "text",
      description: "버튼 텍스트",
    },
    disabled: {
      control: "boolean",
      description: "버튼 비활성화 여부",
    },
    onClick: { 
      action: "clicked", 
      description: "클릭 이벤트" 
    },
  },
  args: {
    onClick: fn(),
  },
} satisfies Meta<typeof PrimaryButton>;

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


// 각 테마별로 스토리 생성

export const Dark: Story = {
  args: {
    children: "계속하기",
    theme: "dark",
    disabled: false,
  },
};

export const Light: Story = {
  args: {
    children: "계속하기",
    theme: "light",
    disabled: false,
  },
};

export const Social: Story = {
  args: {
    children: "Google로 시작하기",
    theme: "social",
    disabled: false,
  },
};

export const Text: Story = {
  args: {
    children: "더보기",
    theme: "text",
    disabled: false,
  },
};

export const Disabled: Story = {
  args: {
    children: "계속하기",
    theme: "dark",
    disabled: true,
  },
};

여러 스토리 작성의 장점:

  • 모든 상태를 한눈에 확인할 수 있습니다
  • 각 테마의 시각적 차이를 바로 비교할 수 있습니다
  • 디자이너와 협업 시 모든 케이스를 빠르게 검토할 수 있습니다

Generic Type으로 타입 안전성 확보하기

TagList 컴포넌트 구현:

TagList는 여러 TagButton으로 구성되며, 선택된 태그를 부모에게 전달해야 합니다. 이때 Generic Type을 사용하면 태그 값의 타입을 안전하게 관리할 수 있습니다.

src/components/TagList.tsx****:

typescript
// Generic Type으로 태그 타입 지정

interface ITagListProps<T extends string> {
  tags: readonly T[];           
// 태그 목록 (readonly로 불변성 보장)

  selectedTag: T;               
// 현재 선택된 태그

  onTagClick: (tag: T) => void; 
// 태그 클릭 핸들러

}

export default function TagList<T extends string>({
  tags,
  selectedTag,
  onTagClick,
}: ITagListProps<T>) {
  return (
    <div 
      className="flex gap-4"
      
// Event Bubbling으로 클릭 이벤트 처리

      onClick={(event) => {
        const target = event.target as HTMLButtonElement;
        
// textContent를 타입 단언으로 처리

        const clickedTag = target.textContent as T;
        onTagClick(clickedTag);
      }}
    >
      {tags.map((tag) => (
        <button
          key={tag}
          className={`
            px-4 py-2 rounded-full
            ${tag === selectedTag 
              ? 'bg-primary text-white' 
              : 'bg-mono-100 text-primary'}
          `}
        >
          {tag}
        </button>
      ))}
    </div>
  );
}

Generic Type의 장점:

typescript
// ✅ 타입 안전: 올바른 태그만 전달 가능

const categories = ['개발', '디자인', '기획'] as const;
type Category = typeof categories[number]; 
// '개발' | '디자인' | '기획'


function handleTagClick(tag: Category) {
  console.log(tag); 
// '개발' | '디자인' | '기획'만 가능

}


// ❌ 컴파일 에러: '마케팅'은 허용되지 않음

handleTagClick('마케팅');

Event Bubbling 이해하기:

Event Bubbling은 자식 요소에서 발생한 이벤트가 부모 요소로 전파되는 것을 말합니다.

typescript
<div onClick={handleParentClick}>        {
/* 3. 마지막으로 여기 도달 */
}
  <ul onClick={handleListClick}>         {
/* 2. 여기로 전파 */
}
    <li>
      <button onClick={handleButtonClick}> {
/* 1. 클릭 발생 */
}
        클릭
      </button>
    </li>
  </ul>
</div>

TagList에서의 활용:

  • 각 버튼에 개별 핸들러를 달지 않고
  • 부모 div에 하나의 핸들러만 달아서
  • 클릭된 버튼의 정보를 받아옵니다

장점:

  • 이벤트 핸들러 수를 줄일 수 있습니다
  • 동적으로 추가되는 요소에도 자동으로 적용됩니다
  • 메모리 사용량이 감소합니다

src/stories/TagList.stories.tsx****:

typescript
import type { Meta, StoryObj } from "@storybook/react";
import TagList from "../components/TagList";
import { fn } from "@storybook/test";

const categories = ['전체', '개발', '디자인', '기획', '마케팅'] as const;

const meta = {
  title: "Components/TagList",
  component: TagList,
  parameters: {
    layout: "centered",
  },
  tags: ["autodocs"],
  argTypes: {
    tags: {
      control: "object",
      description: "표시할 태그 목록",
    },
    selectedTag: {
      control: "text",
      description: "현재 선택된 태그",
    },
    onTagClick: {
      action: "tag-clicked",
      description: "태그 클릭 이벤트",
    },
  },
  args: {
    onTagClick: fn(),
  },
} satisfies Meta<typeof TagList<typeof categories[number]>>;

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

export const Default: Story = {
  args: {
    tags: categories,
    selectedTag: '전체',
  },
};

export const DesignSelected: Story = {
  args: {
    tags: categories,
    selectedTag: '디자인',
  },
};