Storybook 입문 슈웃~

최씨·2024년 11월 10일
5

Frontend

목록 보기
1/8
post-thumbnail

🍀 Storybook 이란?

컴포넌트의 다양한 경우를 정리할 수 있도록 도와주는 시각화 도구(tool)

예를 들어, 버튼 컴포넌트가 있으면 상태(데이터)에 따라서 UI나 액션이 달라질 수 있다.
variant 값이 primary 이면 일반버튼, dashed 이면 점선이 있는 버튼으로 버튼 UI를 표현할 수 있다.

점점 늘어나는 컴포넌트들을 한눈에 정리하기 힘들때,
스토리북은 이런 컴포넌트들을 시각적으로 정리 + 구현 코드를 문서화

디자이너와 협업할때 컴포넌트의 UI가 잘 구현되었는지 피드백을 받을 수 있으며,
다른 개발자와 협업할 때는 컴포넌트를 어떻게 구현했는지 + 사용방법을 스토리북을 통해 보여줄 수 있다.
아토믹 디자인 패턴을 사용한다면 스토리북은 더 강력한 툴이 될 수 있다.

파일명 컨벤션: <컴포넌트명>.stories.tsx

React + Typescript + Vite + Tailwind CSS + pnpm 기반 코드입니다.


🍀 Storybook 설치

// npm
npx storybook@latest init

// yarn
yarn dlx storybook@latest init

// pnpm
pnpm dlx storybook@latest init

설치하면 package.json에 scripts 부분에 storybook과 build-storybook 스크립트가 추가된 것 확인 가능.

각 스크립트는 스토리북을 로컬서버로 실행시키거나 스토리북을 빌드(원격 서버에 배포)해주는 역할을 한다.

폴더구조는 (yarn 기준) 대략 밑처럼 이루어집니다.

.
├── .storybook
├── node_modules
├── public
├── src
├── .gitignore
├── .index.html
├── LICENSE
├── package.json
├── yarn.lock
├── vite.config.js
└── README.md

🍀 main.ts 파일과 preview.ts 파일

스토리북 설치를 완료하면, .storybook 폴더 하위에 main.tspreview.ts 파일이 생성됩니다.

main.ts 파일에서 스토리북에 대한 전반적인 설정을 할 수 있습니다.

// .storybook/main.ts

/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
  // 스토리 파일의 위치를 지정하는 배열. 여기서는 src 폴더 내 .mdx와 .stories.js, .stories.ts 등 확장자를 가진 파일들을 스토리로 불러옵니다.
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  
  // Storybook에 추가할 애드온 목록
  addons: [
    '@storybook/addon-onboarding',   // Storybook을 처음 사용하는 사용자를 돕기 위한 온보딩 애드온
    '@storybook/addon-essentials',   // 기본적으로 필요한 기능 모음으로, Controls, Actions, Docs, Viewport 등이 포함
    '@chromatic-com/storybook',      // Chromatic과 연동하여 비주얼 테스팅과 UI 리뷰 기능을 추가하는 애드온
    '@storybook/addon-interactions', // 상호작용 테스트 기능을 제공하여 스토리에서 사용자의 동작을 시뮬레이션할 수 있도록 지원
	  '@storybook/addon-postcss',      // Tailwind CSS와 같은 PostCSS 기반의 CSS 프레임워크를 사용할 때 필요
  ],
  
  // Storybook에서 사용할 프레임워크 및 빌더 설정
  framework: {
    name: '@storybook/react-vite',   // React + Vite 기반의 Storybook 설정을 사용
    options: {},                     // 추가 옵션을 지정할 수 있지만 여기서는 기본 설정을 사용
  },
};

export default config;

preview.ts 는 preview(미리보기) 화면에 대한 설정을 적용할 수 있습니다.

// .storybook/preview.ts

import "../src/index.css"; // Tailwind CSS 스타일 파일을 import

/** @type { import('@storybook/react').Preview } */  // Storybook의 Preview 타입을 지정하여 타입 자동 완성을 활성화
const preview = {
  parameters: {  // Storybook에서 사용할 전역 파라미터 설정
    controls: {  // 컨트롤 패널 설정으로, 각 props에 맞는 컨트롤러를 자동 지정
      matchers: {  // 특정 패턴을 가진 props를 자동으로 매핑
        color: /(background|color)$/i,  // props 이름에 'background' 또는 'color'가 포함되면 색상 선택기를 적용
        date: /Date$/i,  // props 이름이 'Date'로 끝나면 날짜 선택기를 적용
      },
    },
  },
};

export default preview;
  • 미리보기(Preview)는 컴포넌트 (스토리)를 미리 보여주는 영역이라고 할 수 있습니다.
  • preview.ts 를 통해 UI가 어떻게 렌더링될 것인지 설정할 수 있습니다.
  • preview.ts 내부에 CSS를 import하거나 Javascript를 로드할 수도 있는데,
  • normalize.css같은 CSS를 import 하면 브라우저 별로 조금씩 다른 CSS를 정리해 볼 수 있습니다. (크로스 브라우징)

🍀 Storybook 실행

// npm
npm run storybook
 
// npm
yarn storybook

// pnpm
pnpm run storybook

기본으로 http://localhost:6006/ 포트에서 스토리북이 실행됩니다.

  • 6006 포트로 들어가면 보이는 기본 화면입니다.
  • 좌측 탭을 보면 기본 세팅으로 EXAMPLE 에 Button, Header, Page가 있습니다.
    • 기본 세팅에 있는 예제 코드는 src/stories 안에 있습니다 → 필요없으면 삭제해도 문제없습니다.

<컴포넌트명>.stories.tsx 템플릿

import { Meta, StoryObj } from '@storybook/react';
import MyComponent from './MyComponent';

// 메타 데이터. 제너릭에 Button 컴포넌트의 타입을 넘겨준다.
const meta: Meta<typeof MyComponent> = {
  title: 'Category/MyComponent', // 사이드바에 표시할 카테고리
  component: MyComponent, // 컴포넌트
  // 특정 스토리나 컴포넌트에 대해 다양한 설정을 적용하는 객체
  parameters: {
    layout: 'centered', //  Storybook에서 컴포넌트의 레이아웃을 중앙에 배치
  },
  tags: ['autodocs'], // 컴포넌트에 대한 문서를 자동으로 생성
};

// 메타 데이터를 디폴트로 export
export default meta;

// 스토리 타입. StoryObj의 제너릭에 컴포넌트의 타입을 넘겨준다.
type Story = StoryObj<typeof MyComponent>;

// 하나의 스토리. 스토리는 named export 해준다.
// 스토리 이름도 사이드바 카테고리에 표시된다.
export const Default: Story = {
	// 컴포넌트에 필요한 argumets
  args: {
    // propsname: value,
  },
};

매번 이 템플릿을 작성하는건 반복 노가다이므로 snippet을 만들어서 쓰자.


🍀 기본 예제 1: Button

Button.tsx

// src/Button.tsx
type ButtonProps = {
  label: string;
  onClick?: () => void;
  variant?: "blue" | "gray";
};

const Button = ({ label, onClick, variant = "blue" }: ButtonProps) => {
  return (
    <button
      onClick={onClick}
      className={`px-4 py-2 rounded-md text-white font-semibold ${
        variant === "blue" ? "bg-blue-500 hover:bg-blue-600" : "bg-gray-500 hover:bg-gray-600"
      }`}
    >
      {label}
    </button>
  );
};

export default Button;

Button.stories.tsx

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

const meta: Meta<typeof Button> = {
  title: "Category/Button",
  component: Button,
  parameters: {
    layout: "centered",
  },
  tags: ["autodocs"],
  argTypes: {
    onClick: { action: "clicked" },
    variant: {
      control: "select",
      options: ["blue", "gray"],
    },
  },
};

export default meta;

type Story = StoryObj<typeof Button>;

export const Blue: Story = {
  args: {
    label: "Blue Button",
    variant: "blue",
  },
};

export const Gray: Story = {
  args: {
    label: "Gray Button",
    variant: "gray",
  },
};
  • Blue와 Gray두가지 버튼 타입을 작성했습니다.

화면

  • 좌측 탭에 CATEGORY 부분에 Button 부분이 새로 생긴것을 볼 수 있습니다.
    • Docs에 해당 Button에 대한 전체 설명, 그리고 그 밑에 두가지 종류의 버튼을 볼 수 있습니다.
  • 각 계층별 명칭을 밑처럼 지칭하겠습니다.
    • 폴더: CATEGORY 계층
    • 컴포넌트: Button 계층
    • 스토리: Blue, Gray 계층

  • 코드에서 작성한 args 부분을 변경해보고, 바뀐 모습을 바로 확인할 수 있습니다.
  • 또한, 클릭 이벤트(onClick)도 동작하는걸 확인할 수 있습니다.

🍀 기본 예제 2: Task

Task.tsx

type TaskProps = {
  task: {
    id: string;
    title: string;
    state: "DEFAULT" | "PINNED" | "ARCHIVED";
  };
  onArchiveTask: (id: string) => void;
  onPinTask: (id: string) => void;
};

const Task = ({ task: { id, title, state }, onArchiveTask, onPinTask }: TaskProps) => (
  <div
    className={`flex justify-between p-4 border ${
      state === "ARCHIVED" ? "bg-gray-200" : "bg-white"
    }`}
  >
    <label className="flex items-center">
      <input
        type="checkbox"
        checked={state === "ARCHIVED"}
        className="text-blue-500"
        onClick={() => onArchiveTask(id)}
      />
      <span className={`${state === "ARCHIVED" ? "line-through" : ""}`}>{title}</span>
    </label>
    {state !== "ARCHIVED" && (
      <button
        onClick={() => onPinTask(id)}
        className={`p-1 ${
          state === "PINNED"
            ? "text-blue-500 hover:text-blue-800"
            : "text-gray-400 hover:text-gray-800"
        } `}
      >
        <svg
          xmlns="http://www.w3.org/2000/svg"
          className="h-5 w-5"
          viewBox="0 0 20 20"
          fill="currentColor"
        >
          <path d="M6 2a1 1 0 00-1 1v12a1 1 0 001.707.707L10 13.414l3.293 3.293A1 1 0 0015 15V3a1 1 0 00-1-1H6z" />
        </svg>
      </button>
    )}
  </div>
);

export default Task;
  • 기본, 고정, 완료 → 3가지 버튼을 만들었습니다.
  • 클릭시 이벤트 2종류 →onArchiveTask(체크 부분 클릭 시), onPinTask(저장 핀 부분 클릭 )

Task.stories.tsx

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

const meta: Meta<typeof Task> = {
  title: "Category/Task",
  component: Task,
  tags: ["autodocs"],
  argTypes: {
    onArchiveTask: { action: "onArchiveTask" },
    onPinTask: { action: "onPinTask" },
  },
};

export default meta;

type Story = StoryObj<typeof Task>;

export const Default: Story = {
  args: {
    task: {
      id: "1",
      title: "기본 업무",
      state: "DEFAULT",
    },
  },
};

export const Pinned: Story = {
  args: {
    task: {
      id: "2",
      title: "고정된 업무",
      state: "PINNED",
    },
  },
};

export const Archived: Story = {
  args: {
    task: {
      id: "3",
      title: "완료한 업무",
      state: "ARCHIVED",
    },
  },
};

화면


🍀 배포

필요한 이유: 로컬에서만 확인하는것을 넘어, 팀원 모두 UI 문서를 언제 어디서나 쉽게 접근할 수 있게 함

먼저 깃허브에 레포를 만들고, 밑의 과정을 진행한다.

(편의를 위해서 브랜치를 따로 안파고 master 브랜치에서 바로 작업했습니다.)

git init

git add .

git commit -m "first commit"

git remote add origin https://github.com/<your username>/<프로젝트 명>.git

git push -u origin master

pnpm add -D chromatic

→ 크로마틱에 로그인 https://www.chromatic.com/

→ 만든 폴더 이름과 동일한 이름으로 프로젝트 만든다.

Choose GitHub repo를 클릭하고 저장소를 선택해주세요

pnpx chromatic --project-token=<project-token>

→ 완료되면 배포된 스토리북의 https://random-uuid.chromatic.com 링크를 받는다.

배포 완료 화면

  • 튜토리얼에서 맨 처음 배포를 완료하고, 한 번 변경사항을 만들고 다시 배포해야 합니다.
    • 저는 변경없이 한번 배포를 더해서 Build2가 생성되었습니다
    • 마지막에 Button의 텍스트만 바꾸고 배포하니, Build3가 생성되었습니다.
    • 변경사항이 있으면 초록색 원이 아니라 노란색 원으로 뜹니다.(Build3이 처음에 노란색이었습니다) 이것을 들어가면 변경사항을 확인할 수 있고, accept 하면 초록색으로 변경됩니다.
  • 참고: 현재까지 예제로는 Components 2개 Stories 5개가 맞는데, 제가 다른거 시험중이었어서 사진에Components 3개 Stories 9개로 보입니다.

  • 제가 변경사항을 확인하고 accept하여 “Accepted”라고 뜬 부분을 확인할 수 있습니다.

🍀 크로마틱을 통한 지속적 배포

매번 수정 후 수동으로 pnpx chromatic --project-token=<project-token> 하기 귀찮으면 자동화 ㄱㄱ

프로젝트의 기본 폴더에 .github라는 새로운 디렉토리를 만들고 그 안에 workflows라는 디렉토리를 만들어주세요. 그리고 그 안에 chromatic.yml이라는 파일을 생성해주세요.

chromatic.yml

# Workflow name
name: "Chromatic Deployment"

# Event for the workflow
on: push

# List of jobs
jobs:
  chromatic:
    name: "Run Chromatic"
    runs-on: ubuntu-latest
    # Job steps
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Install pnpm
        run: npm install -g pnpm
      - name: Install dependencies
        run: pnpm install
      - name: Build project
        run: pnpm build 
      #👇 Adds Chromatic as a step in the workflow
      - uses: chromaui/action@latest
        # Options required for Chromatic's GitHub Action
        with:
          #👇 Chromatic projectToken, see https://storybook.js.org/tutorials/intro-to-storybook/react/ko/deploy/ to obtain it
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
          token: ${{ secrets.GITHUB_TOKEN }}
  • CHROMATIC_PROJECT_TOKEN 설정 방법
    • Chromatic에 로그인 후, 기존 프로젝트 선택
    • 프로젝트의 Manage 페이지로 이동
    • 아래로 스크롤하면 Project Token을 복사할 수 있는 곳이 있습니다.
    • 복사 후 붙여넣기
  • GITHUB_TOKEN 설정 방법
    • GitHub가 자동으로 제공하는 인증 토큰으로, 일반적으로 GitHub Actions 내에서 자동으로 사용할 수 있어, 별도로 생성할 필요가 없습니다.
    • ${{ secrets.GITHUB_TOKEN }}에 따로 넣을 필요 없이, GitHub Secrets에 추가만 하면 됩니다.
    • GitHub 리포지토리로 이동
    • Settings > Secrets and variables > Actions로 이동
    • New repository secret을 클릭
    • Name에 CHROMATIC_PROJECT_TOKEN을 입력하고, Value에 Chromatic에서 얻은 Project Token을 붙여 넣습니다.
    • Add secret을 클릭하여 저장합니다.

이후 밑의 작업을 순서대로 진행합니다.

git add .

git commit -m "GitHub action setup"

git push origin master

성공 여부 확인

  • 깃허브 → Actions에 들어가서 저렇게 초록 체크 있으면 성공입니다.
    • 커밋 메세지는 chromatic.yml 에러 수정하느라 다른겁니다 ㅎㅎ
    • 위에 올려둔 chromatic.yml 파일이 최종이라 걱정 안하셔도 됩니다.

  • 수정후 커밋하면, 자동으로 Buid4가 생성된 것을 볼 수 있습니다 ㅎㅎ

  • Build 4를 들어가면 다음과 같이 달라진 변경사항을 자세히 확인할 수 있습니다.
  • 참고: 이전 Build를 승인 안해주고 다음 Build를 생성하면, 이전 Build를 승인할 수 없습니다. (현재 상황)
    • 깜박한게 아니라, 해당 사항을 보여주기 위해 일부러 그런거라면 믿어줄래?

🍀 심화 예제 1: TaskList

TaskList.tsx

import Task from "./Task";

type Ttask = {
  id: string;
  title: string;
  state: "DEFAULT" | "PINNED" | "ARCHIVED";
};

type TaskListProps = {
  loading: boolean;
  tasks: Ttask[];
  onPinTask: (id: string) => void;
  onArchiveTask: (id: string) => void;
};

export default function TaskList({ loading, tasks, onPinTask, onArchiveTask }: TaskListProps) {
  const events = {
    onPinTask,
    onArchiveTask,
  };

  if (loading) {
    return <div>로딩 중...</div>;
  }

  if (tasks.length === 0) {
    return <div>업무가 없음!</div>;
  }

  return (
    <div className="space-y-2">
      {tasks.map((task) => (
        <Task key={task.id} task={task} {...events} />
      ))}
    </div>
  );
}

TaskList.stories.tsx

import { Meta, StoryObj } from "@storybook/react";
import TaskList from "./TaskList";
import * as TaskStories from "./Task.stories";

const meta: Meta<typeof TaskList> = {
  component: TaskList,
  title: "Category/TaskList",
  decorators: [(story) => <div>{story()}</div>],
  tags: ["autodocs"],
  args: {
    ...(TaskStories.Default.args || {}),
  },
};

export default meta;

type Story = StoryObj<typeof TaskList>;

export const Default: Story = {
  args: {
    tasks: [
      { ...(TaskStories.Default.args?.task || {}), id: "1", title: "Task 1", state: "DEFAULT" },
      { ...(TaskStories.Default.args?.task || {}), id: "2", title: "Task 2", state: "DEFAULT" },
      { ...(TaskStories.Default.args?.task || {}), id: "3", title: "Task 3", state: "DEFAULT" },
      { ...(TaskStories.Default.args?.task || {}), id: "4", title: "Task 4", state: "DEFAULT" },
      { ...(TaskStories.Default.args?.task || {}), id: "5", title: "Task 5", state: "DEFAULT" },
      { ...(TaskStories.Default.args?.task || {}), id: "6", title: "Task 6", state: "DEFAULT" },
    ],
  },
};

export const WithPinnedTasks: Story = {
  args: {
    tasks: [
      ...(Default.args?.tasks?.slice(0, 5) || []),
      { id: "6", title: "Task 6 (pinned)", state: "PINNED" },
    ],
  },
};

export const Loading: Story = {
  args: {
    tasks: [],
    loading: true,
  },
};

export const Empty: Story = {
  args: {
    ...Loading.args,
    loading: false,
  },
};

accept 전 / accept 후

화면


🍀 참고 링크

profile
코딩 근육을 키워볼까요?

1개의 댓글

comment-user-thumbnail
2024년 11월 15일

허정무 슛

답글 달기

관련 채용 정보