Back to Intro to Storybook
Chapters
  • 시작하기
  • 간단한 컴포넌트
  • 복합적 컴포넌트
  • 데이터
  • 화면
  • 배포하기
  • 테스트
  • 접근성 테스트
  • 마무리
  • 기여하기

간단한 컴포넌트 만들기

간단한 컴포넌트를 독립적으로 만들어봅시다

우리는 컴포넌트 기반 개발(Component-Driven Development)(CDD) 방법론에 따라 UI를 만들어 볼 것입니다. 이는 컴포넌트부터 시작하여 마지막 화면에 이르기까지 상향식(bottom-up)으로 UI를 개발하는 과정입니다. CDD는 UI를 구현할 때 직면하게 되는 규모의 복잡성을 해결하는 데 도움이 됩니다.

Task 컴포넌트

Task 컴포넌트의 3가지 상태(states)

Task는 우리 앱의 핵심 컴포넌트입니다. 각각의 task는 현재 어떤 상태에 있는지에 따라 약간씩 다르게 나타납니다. 선택된(또는 선택되지 않은) 체크 박스, task에 대한 정보, 그리고 task를 위아래로 움직일 수 있도록 도와주는 '핀' 버튼이 표시될 것입니다. 이를 위해 다음과 같은 prop들이 필요합니다.

  • title – task를 설명해주는 문자열
  • state - 현재 어떤 task가 목록에 있으며, 선택되어 있는지의 여부

Task 컴포넌트를 만들기 위해, 위에서 살펴본 여러 유형의 task에 해당하는 테스트 상태를 작성합니다. 그런 다음 모의 데이터를 사용하여 독립적 환경에서 컴포넌트를 구축하기 위해 스토리북(Storybook)을 사용합니다. 각각의 상태에 따른 컴포넌트의 모습을 "시각적으로 테스트" 하며 진행할 것입니다.

설정하기

먼저 Task 컴포넌트와 그에 해당하는 스토리 파일을 만들어 봅시다. src/components/Task.tsxsrc/components/Task.stories.tsx을 생성해 주세요.

Task의 기본 구현부터 시작하겠습니다. 우리가 필요로 하는 속성들과 여러분이 task에 대해 취할 수 있는 두 가지 액션(목록 간 이동하는 것)을 간단히 살펴보도록 하겠습니다.

Copy
src/components/Task.tsx
type TaskData = {
  id: string;
  title: string;
  state: 'TASK_ARCHIVED' | 'TASK_INBOX' | 'TASK_PINNED';
};

type TaskProps = {
  task: TaskData;
  onArchiveTask: (id: string) => void;
  onPinTask: (id: string) => void;
};

export default function Task({
  task: { id, title, state },
  onArchiveTask,
  onPinTask,
}: TaskProps) {
  return (
    <div className="list-item">
      <label htmlFor={`title-${id}`} aria-label={title}>
        <input
          type="text"
          value={title}
          readOnly={true}
          name="title"
          id={`title-${id}`}
        />
      </label>
    </div>
  );
}

위의 코드는 Todos 앱의 기존 HTML을 기반으로 Task에 대한 간단한 마크업을 렌더링 합니다.

아래의 코드는 Task의 세 가지 테스트 상태를 스토리 파일에 작성한 것입니다.

Copy
src/components/Task.stories.tsx
import type { Meta, StoryObj } from '@storybook/react-vite';

import { fn } from 'storybook/test';

import Task from './Task';

export const ActionsData = {
  onArchiveTask: fn(),
  onPinTask: fn(),
};

const meta = {
  component: Task,
  title: 'Task',
  tags: ['autodocs'],
  //👇 Our exports that end in "Data" are not stories.
  excludeStories: /.*Data$/,
  args: {
    ...ActionsData,
  },
} satisfies Meta<typeof Task>;

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

export const Default: Story = {
  args: {
    task: {
      id: '1',
      title: 'Test Task',
      state: 'TASK_INBOX',
    },
  },
};

export const Pinned: Story = {
  args: {
    task: {
      ...Default.args.task,
      state: 'TASK_PINNED',
    },
  },
};

export const Archived: Story = {
  args: {
    task: {
      ...Default.args.task,
      state: 'TASK_ARCHIVED',
    },
  },
};

💡 Actions는 UI 컴포넌트를 독립적으로 만들 때 상호작용을 확인하는 데 도움을 줍니다. 종종 앱의 컨텍스트에서 사용하는 함수나 상태에 접근할 수 없을 때가 있습니다. 이때 fn()을 사용해 해당 함수들을 임시로 대체하세요.

스토리북에는 두 가지 기본 구성 단계가 있습니다: 컴포넌트와 그 하위 스토리들입니다. 각 스토리를 컴포넌트의 변형이라고 생각해보세요. 한 컴포넌트는 필요한 만큼 많은 스토리를 가질 수 있습니다.

  • 컴포넌트
    • 스토리(story)
    • 스토리(story)
    • 스토리(story)

스토리북에게 우리가 테스트하고 있는 컴포넌트에 대해 알려주기 위해, 아래 사항들을 포함하는 default export를 생성합니다:

  • component -- 컴포넌트 자체
  • title -- 스토리북 사이드바에서 컴포넌트를 그룹화하거나 분류하는 방법
  • tags -- 컴포넌트에 대한 문서를 자동으로 생성하기 위한 태그
  • excludeStories -- 스토리에 필요하지만 스토리북에서 렌더링되지 않아야 하는 추가 정보
  • args -- 컴포넌트가 사용자 정의 이벤트를 모킹하기 위해 기대하는 액션 args를 정의

스토리를 정의하기 위해, 우리는 Component Story Format 3 (CSF3로도 알려진)를 사용하여 각 테스트 케이스를 구현할 것입니다. 이 포맷은 각 테스트 케이스를 간결하게 구현하도록 설계되었습니다. 각 컴포넌트의 상태를 포함하는 객체를 내보냄으로써, 우리는 테스트를 보다 직관적으로 정의하고 스토리를 더 효율적으로 작성 및 재사용할 수 있습니다.

Arguments(인수) 혹은 줄여서 args를 사용하면 스토리북을 다시 시작하지 않고도 스토리북의 controls addon을 통해 컴포넌트를 라이브로 편집할 수 있습니다. args값이 변경되면 컴포넌트도 함께 변경됩니다.

fn()을 사용하면 클릭 시 스토리북 UI의 Actions 패널에 나타나는 콜백을 생성할 수 있습니다. 따라서 핀 버튼을 만들 때 버튼 클릭이 UI에서 성공적으로 이루어졌는지를 확인할 수 있습니다.

모든 컴포넌트의 모든 조합에 동일한 액션 세트를 전달해야 하므로, 이를 하나의 ActionsData 변수로 묶어 매번 스토리 정의에 전달하는 것이 편리합니다. 컴포넌트가 필요로 하는 ActionsData를 묶는 또 다른 장점은, 이를 export하고 나중에 이 컴포넌트를 재사용하는 컴포넌트의 스토리에서 사용할 수 있다는 것입니다. 나중에 살펴보겠습니다.

환경설정

최근에 생성한 스토리를 인식하고 애플리케이션의 CSS 파일(src/index.css에 위치한)을 사용하기 위해 스토리북 구성 파일에 몇 가지 변경 사항이 필요합니다.

먼저 스토리북 구성 파일(.storybook/main.ts)을 다음과 같이 변경합니다:

Copy
.storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
- stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
+ stories: ['../src/components/**/*.stories.@(ts|tsx)'],
  staticDirs: ['../public'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-docs',
    '@storybook/addon-vitest',
    '@chromatic-com/storybook',
  ],
  framework: {
    name: '@storybook/react-vite',
    options: {},
  },
};

export default config;

위와 같이 변경을 마치셨다면, .storybook 폴더 내의 preview.ts를 다음과 같이 변경합니다:

Copy
.storybook/preview.ts
import type { Preview } from '@storybook/react-vite';

+ import '../src/index.css';

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

export default preview;

매개변수(parameters)는 일반적으로 스토리북의 기능과 애드온의 동작을 제어하는 데 사용됩니다. 하지만 이번 경우에는 그 목적으로 사용하지 않을 것입니다. 대신에 우리는 애플리케이션의 CSS 파일을 import할 것입니다.

actions은 클릭이 되었을 때 스토리북 UI의 actions 패널에 나타날 콜백을 생성할 수 있도록 해줍니다. 따라서 pin 버튼을 만들 때, 버튼 클릭이 성공적이었는지 테스트 UI에서 확인 할 수 있을 것입니다.

이 작업을 완료하면 스토리북 서버를 재시작할 때 세 가지 작업(Task) 상태에 대한 테스트 케이스가 생성될 것입니다:

상태(States) 구현하기

이제 스토리북 설정, 스타일 가져오기, 테스트 케이스를 구현했으므로 디자인에 맞춰 컴포넌트의 HTML을 빠르게 구현할 수 있습니다.

컴포넌트는 아직 기본만 갖춘 상태입니다. 우선, 자세한 사항은 생략하고 디자인 코드를 작성해보겠습니다.

Copy
src/components/Task.tsx
type TaskData = {
  id: string;
  title: string;
  state: 'TASK_ARCHIVED' | 'TASK_INBOX' | 'TASK_PINNED';
};

type TaskProps = {
  /** Composition of the task */
  task: TaskData;
  /** Event to change the task to archived */
  onArchiveTask: (id: string) => void;
  /** Event to change the task to pinned */
  onPinTask: (id: string) => void;
};

export default function Task({
  task: { id, title, state },
  onArchiveTask,
  onPinTask,
}: TaskProps) {
  return (
    <div className={`list-item ${state}`}>
      <label
        htmlFor={`archiveTask-${id}`}
        aria-label={`archiveTask-${id}`}
        className="checkbox"
      >
        <input
          type="checkbox"
          disabled={true}
          name="checked"
          id={`archiveTask-${id}`}
          checked={state === "TASK_ARCHIVED"}
        />
        <span className="checkbox-custom" onClick={() => onArchiveTask(id)} />
      </label>

      <label htmlFor={`title-${id}`} aria-label={title} className="title">
        <input
          type="text"
          value={title}
          readOnly={true}
          name="title"
          id={`title-${id}`}
          placeholder="Input title"
        />
      </label>
      {state !== "TASK_ARCHIVED" && (
        <button
          className="pin-button"
          onClick={() => onPinTask(id)}
          id={`pinTask-${id}`}
          aria-label={`pinTask-${id}`}
          key={`pinTask-${id}`}
        >
          <span className={`icon-star`} />
        </button>
      )}
    </div>
  );
}

위의 추가 마크업과 앞서 가져온 CSS를 결합하면 다음과 같은 UI가 생성됩니다:

데이터 요구 사항 명시하기

컴포넌트를 확장해 나가면서 Task 컴포넌트가 어떤 형태의 데이터를 기대하는지 TypeScript 타입으로 정의해 두면 좋습니다.
이렇게 하면 에러를 미리 방지할 수 있고, 컴포넌트가 점점 복잡해져도 올바르게 사용할 수 있습니다.

우선 src 폴더에 types.ts 파일을 만들고, 기존에 사용하던 TaskData 타입을 해당 파일로 옮겨주세요:

Copy
src/types.ts
export type TaskData = {
  id: string;
  title: string;
  state: 'TASK_ARCHIVED' | 'TASK_INBOX' | 'TASK_PINNED';
};

그 다음, 방금 생성한 타입을 Task 컴포넌트에서 사용하도록 업데이트하세요:

Copy
src/components/Task.tsx
import type { TaskData } from '../types';

type TaskProps = {
  /** Composition of the task */
  task: TaskData;
  /** Event to change the task to archived */
  onArchiveTask: (id: string) => void;
  /** Event to change the task to pinned */
  onPinTask: (id: string) => void;
};

export default function Task({
  task: { id, title, state },
  onArchiveTask,
  onPinTask,
}: TaskProps) {
  return (
    <div className={`list-item ${state}`}>
      <label
        htmlFor={`archiveTask-${id}`}
        aria-label={`archiveTask-${id}`}
        className="checkbox"
      >
        <input
          type="checkbox"
          disabled={true}
          name="checked"
          id={`archiveTask-${id}`}
          checked={state === "TASK_ARCHIVED"}
        />
        <span className="checkbox-custom" onClick={() => onArchiveTask(id)} />
      </label>

      <label htmlFor={`title-${id}`} aria-label={title} className="title">
        <input
          type="text"
          value={title}
          readOnly={true}
          name="title"
          id={`title-${id}`}
          placeholder="Input title"
        />
      </label>
      {state !== "TASK_ARCHIVED" && (
        <button
          className="pin-button"
          onClick={() => onPinTask(id)}
          id={`pinTask-${id}`}
          aria-label={`pinTask-${id}`}
          key={`pinTask-${id}`}
        >
          <span className={`icon-star`} />
        </button>
      )}
    </div>
  );
}

이제 Task 컴포넌트가 잘못 사용된다면 경고가 나타날 것입니다.

컴포넌트 완성!

지금까지 우리는 서버나 프런트엔드 앱 전체를 실행하지 않고도 성공적으로 컴포넌트를 만들었습니다. 다음 단계는 비슷한 방식으로 나머지 Taskbox 컴포넌트를 하나씩 만드는 것입니다.

보시다시피, 컴포넌트를 독립적으로 구현하는 것은 쉽고 빠릅니다. 가능한 모든 상태를 테스트할 수 있기 때문에 버그가 적은 고품질 UI를 만들 수 있을 것입니다.

💡 변경된 사항을 깃(Git)에 commit하는 것을 잊지 마세요!
Keep your code in sync with this chapter. View 1e576c5 on GitHub.
Is this free guide helping you? Tweet to give kudos and help other devs find it.
Next Chapter
복합적 컴포넌트
간단한 컴포넌트로 복합적 컴포넌트를 조합해보세요
✍️ Edit on GitHub – PRs welcome!
Join the community
7,932 developers and counting
WhyWhy StorybookComponent-driven UI
DocsGuidesTutorialsChangelogTelemetry
CommunityAddonsGet involvedBlog
ShowcaseExploreAbout
Open source software
Storybook

Maintained by
Chromatic
Special thanks to Netlify and CircleCI