[FE] Storybook을 아십니까

.DS_Store·2024년 1월 17일
1

FrontEnd

목록 보기
22/26
post-custom-banner

최근 회사에서 스토리북 도입의 필요성에 대한 이야기가 나와 공부할 겸 찾아봤는데..!

튜토리얼만 따라하는데 엥 에러가!?

알고보니 한국어 버전은...ㅠㅠ

짜잔~! 최신 버전이 아니었답니다~!

ㅎㅎ..
따라서!

스토리북 공식 사이트 튜토리얼 영문ver 보면서 정리한 것 몇 가지와 실제로 적용하면서 해결한 문제나 배운 것 등등을… 미래의 나를 위해 간단히 정리해본다.

Storybook

일단! 스토리북이 뭐죠?

(image: 스토리북 공식 메인 페이지)

사이트의 소개에 따르면 귀찮지 않게 UI작업을 할 수 있다고 하고, 또 페이지랑 UI 컴포넌트를 별개로 작업하는 데에 도움을 준다고 한다.

백문이 불여일견
사용 예시 사진을 바로 보자

컴포넌트 하나가 덜렁 올라와있고
왼쪽 메뉴에 보면 Default, Pinned, Archived로 Task가 나뉘어있는 것을 볼 수 있다.

이걸 리스트로 만든 형태를 또 스토리북에 올리면 아래와 같이 만들 수 있다.

이 역시 With Pinned Tasks, Loading, Empty 등의 스토리가 추가로 작성되어있다.

이렇게 스토리북은 UI를 분리해
스토리를 기준으로 UI를 구성하고 보여지는 모습을 확인할 수 있게 해준다.
따라서 개별 컴포넌트에 대한 테스트가 용이해진다.

후에 한 번 더 언급하겠지만 이런식으로 개별 컴포넌트를 구성하는 것을 의식함으로서 컴포넌트간의 의존성도 줄일 수 있게 되고
컴포넌트 기반의 개발을 진행하는 데에도 큰 이점이 있다.

스토리북 그럼 어떻게 쓰나요?

설치

이미 진행 중인 프로젝트가 있다면 해당 프로젝트에서, 없다면 프로젝트를 생성하고

npx storybook@latest init

를 작성하면 되고

만약 템플릿을 받아 튜토리얼을 진행해보고 싶다면 아래의 커맨드 라인을 작성하자.

# Clone the template
npx degit chromaui/intro-storybook-react-template taskbox

cd taskbox

# Install dependencies
yarn

설치 에러

만약 에러가 났다면?

나같은 경우는 typescript-eslint 에러가 한 번, 절대 경로 때문에 에러가 한 번 났다.

이 두가지는 패키지 설치로 해결할 수 있었어서,

에러 메세지와 해결 방법만 간단히 적어두겠다.

발생 에러 메세지 (1)

[eslint] Failed to load plugin '@typescript-eslint' declared in '.eslintrc': Package subpath './lib/definition' is not defined by "exports" in /…

해결 방법

프로젝트 루트 디렉토리에서 터미널을 열고 다음 명령어로 필요한 패키지들을 설치
yarn add --dev @typescript-eslint/eslint-plugin @typescript-eslint/parser

발생 에러 메세지(2)

Module not found: `Can't resolve '~/media' in '/~/media' in '/…`

아마 절대 경로를 @ 를 넣어 많이 사용하니

Module not found: Can't resolve '@ 어쩌구 하는 에러가 발생할 확률이 높겠다.

해결 방법

(1) 패키지 설치

yarn add -D tsconfig-paths-webpack-plugin

(2) .storybook/main.ts수정

// .storybook/main.ts
import type { StorybookConfig } from "@storybook/react-webpack5"

const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin")

const config: StorybookConfig = {
  stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
  addons: [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    "@storybook/preset-create-react-app",
    "@storybook/addon-onboarding",
    "@storybook/addon-interactions",
  ],
  framework: {
    name: "@storybook/react-webpack5",
    options: {
      builder: {
        useSWC: true,
      },
    },
  },
  docs: {
    autodocs: "tag",
  },
  staticDirs: ["../public"],
  webpackFinal: async (config) => {
    if (config.resolve) {
      config.resolve.plugins = [
        ...(config.resolve.plugins || []),
        new TsconfigPathsPlugin({
          extensions: config.resolve.extensions,
        }),
      ]
    }
    return config
  },
}

export default config

기타 에러들은 사용하면서 추가되면 (에러 발생 안 하길..)

몇가지 다시 모아서 정리해보든 하겠다!

스토리 작성

설치가 잘 되었다면

yarn storybook으로 스토리북을 열어보자. localhost:6006에서 실행되고, 백문이 불여일견이라며 올렸던 사진과 같은 화면이 보일 것이다.

해당 화면은

//Task.jsx
import React from 'react';
import PropTypes from 'prop-types';

export default function Task({
  task: { id, title, state },
  onArchiveTask,
  onPinTask,
}) {
  return (
    <div className={`list-item ${state}`}>
      <label
        htmlFor="checked"
        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" aria-label={title} className="title">
        <input
          type="text"
          value={title}
          readOnly={true}
          name="title"
          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.propTypes = {
  /** Composition of the task */
  task: PropTypes.shape({
    /** Id of the task */
    id: PropTypes.string.isRequired,
    /** Title of the task */
    title: PropTypes.string.isRequired,
    /** Current state of the task */
    state: PropTypes.string.isRequired,
  }),
  /** Event to change the task to archived */
  onArchiveTask: PropTypes.func,
  /** Event to change the task to pinned */
  onPinTask: PropTypes.func,
};

이 컴포넌트를 기반으로 작성한 스토리 화면이다.

그럼 이제 진짜!
스토리는 어떻게 작성되어있고, 어떻게 작성할까!

아래는 튜토리얼의 예시 코드이자 상단 이미지를 이루는 코드이다.

//Task.stories.js
import Task from './Task';

export default {
  component: Task,
  title: 'Task',
  tags: ['autodocs'],
};

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

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

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

args 객체에 인자를 넘겨주는 식으로 여러 스토리를 구성하고 있고 해당 인자에 맡게 렌더링 된 화면이 스토리북에 올라가는 것이다!

조금 더 복잡한 코드도 봐보자.

할 일을 체크하는 컴포넌트

import React from 'react';
import Task from './Task';
import { useDispatch, useSelector } from 'react-redux';
import { updateTaskState } from '../lib/store';

export default function TaskList() {
  // We're retrieving our state from the store
  const tasks = useSelector((state) => {
    const tasksInOrder = [
      ...state.taskbox.tasks.filter((t) => t.state === 'TASK_PINNED'),
      ...state.taskbox.tasks.filter((t) => t.state !== 'TASK_PINNED'),
    ];
    const filteredTasks = tasksInOrder.filter(
      (t) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED'
    );
    return filteredTasks;
  });

  const { status } = useSelector((state) => state.taskbox);

  const dispatch = useDispatch();

  const pinTask = (value) => {
    // We're dispatching the Pinned event back to our store
    dispatch(updateTaskState({ id: value, newTaskState: 'TASK_PINNED' }));
  };
  const archiveTask = (value) => {
    // We're dispatching the Archive event back to our store
    dispatch(updateTaskState({ id: value, newTaskState: 'TASK_ARCHIVED' }));
  };
  const LoadingRow = (
    <div className="loading-item">
      <span className="glow-checkbox" />
      <span className="glow-text">
        <span>Loading</span> <span>cool</span> <span>state</span>
      </span>
    </div>
  );
  if (status === 'loading') {
    return (
      <div className="list-items" data-testid="loading" key={'loading'}>
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
      </div>
    );
  }
  if (tasks.length === 0) {
    return (
      <div className="list-items" key={'empty'} data-testid="empty">
        <div className="wrapper-message">
          <span className="icon-check" />
          <p className="title-message">You have no tasks</p>
          <p className="subtitle-message">Sit back and relax</p>
        </div>
      </div>
    );
  }

  return (
    <div className="list-items" data-testid="success" key={'success'}>
      {tasks.map((task) => (
        <Task
          key={task.id}
          task={task}
          onPinTask={(task) => pinTask(task)}
          onArchiveTask={(task) => archiveTask(task)}
        />
      ))}
    </div>
  );
}

와 이걸 기반으로 작성된 스토리다.

import TaskList from './TaskList';
import * as TaskStories from './Task.stories';

import { Provider } from 'react-redux';

import { configureStore, createSlice } from '@reduxjs/toolkit';

// A super-simple mock of the state of the store
export const MockedState = {
  tasks: [
    { ...TaskStories.Default.args.task, id: '1', title: 'Task 1' },
    { ...TaskStories.Default.args.task, id: '2', title: 'Task 2' },
    { ...TaskStories.Default.args.task, id: '3', title: 'Task 3' },
    { ...TaskStories.Default.args.task, id: '4', title: 'Task 4' },
    { ...TaskStories.Default.args.task, id: '5', title: 'Task 5' },
    { ...TaskStories.Default.args.task, id: '6', title: 'Task 6' },
  ],
  status: 'idle',
  error: null,
};

// A super-simple mock of a redux store
const Mockstore = ({ taskboxState, children }) => (
  <Provider
    store={configureStore({
      reducer: {
        taskbox: createSlice({
          name: 'taskbox',
          initialState: taskboxState,
          reducers: {
            updateTaskState: (state, action) => {
              const { id, newTaskState } = action.payload;
              const task = state.tasks.findIndex((task) => task.id === id);
              if (task >= 0) {
                state.tasks[task].state = newTaskState;
              }
            },
          },
        }).reducer,
      },
    })}
  >
    {children}
  </Provider>
);

export default {
  component: TaskList,
  title: 'TaskList',
  decorators: [(story) => <div style={{ padding: '3rem' }}>{story()}</div>],
  tags: ['autodocs'],
  excludeStories: /.*MockedState$/,
};

export const Default = {
  decorators: [
    (story) => <Mockstore taskboxState={MockedState}>{story()}</Mockstore>,
  ],
};

export const WithPinnedTasks = {
  decorators: [
    (story) => {
      const pinnedtasks = [
        ...MockedState.tasks.slice(0, 5),
        { id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
      ];

      return (
        <Mockstore
          taskboxState={{
            ...MockedState,
            tasks: pinnedtasks,
          }}
        >
          {story()}
        </Mockstore>
      );
    },
  ],
};

export const Loading = {
  decorators: [
    (story) => (
      <Mockstore
        taskboxState={{
          ...MockedState,
          status: 'loading',
        }}
      >
        {story()}
      </Mockstore>
    ),
  ],
};

export const Empty = {
  decorators: [
    (story) => (
      <Mockstore
        taskboxState={{
          ...MockedState,
          tasks: [],
        }}
      >
        {story()}
      </Mockstore>
    ),
  ],
};

그러면 짠!

이런 식으로 스토리북에서도 동작하게끔 작성할 수 있다.

해당 코드들은 튜토리얼의 코드이며, 공식 사이트에 보다 많은 예시가 있으니 궁금하다면 따라가면서 하나씩 작성 해보는 걸 추천한다!
cf) 튜토리얼만 따라해도 배포가 뚝딱이랍니다!?

그래서 스토리북 좋던가요?

공표되어있는(?) 장단점

일단 널리 알려진 통상적 장단점부터 적자면 이렇다.

장점:

  • 효율적인 테스트와 디버깅 가능.
  • 문서화에 용이하며, 컴포넌트의 사용법을 쉽게 전달 가능.
  • 개발자 간 협업을 용이하게 함.

단점:

  • 초기 학습 곡선이 있을 수 있음.
  • 모든 프로젝트에 적합하지 않을 수 있음.

개인적인 느낌

그렇다면 갓배워서 갓 적용 중인 내 입장에서는?

아직 판단하기는 이르지만, 소통적인 측면에서 큰 이점이 있을 것 같다.
특히 디자인 시스템이 자리잡고 있지 않았다면, 이미 만들어진 컴포넌트와 거의 동일하고 유사한 컴포넌트를 새로 만들어보았을 확률이 높다.

나 역시 스토리북을 고민하다 배워보고, 적용하기로 마음 먹은 게 이 같은 이유에서니 말이다.

하지만 단점이라기보다 어려운 점을 얘기하자면
Atomic하게 컴포넌트가 개발되어있지 않다면 스토리 하나 만드는 것도 좀 어려울 수 있다.
위에서 예상되는 장점으로 적은 것과 연결되는 부분인데,

후에 한 번 더 언급하겠지만 이런식으로 개별 컴포넌트를 구성하는 것을 의식함으로서
컴포넌트간의 의존성도 줄일 수 있게 되고
_컴포넌트 기반의 개발_을 진행하는 데에도 큰 이점이 있다.

(↑ 바로 이 부분)

기존에 작성된 작은 컴포넌트들이 이미 유기적이고, 결합도가 높게 짜여있다면 이걸 적용하면서 코드를 다시 뜯어 고쳐야할 수도 있다.

고치고 난다면 물론 좋은 일이겠지만 그 중간 과정이 어려운 것은 어쩔 수 없을 것이다.

또, 아는 게 많을 수록 활용하기 편한 tool이라는 생각이 들어서, 결국 얼마나 공부해서 잘 써먹느냐에 따라 스토리북의 이점을 가져가는 정도가 다를 것 같다.

개발 생산성이 높아진 후에 다시 한 번 의견을 적어보는 것도 좋겠다!

그럼 이만!

post-custom-banner

0개의 댓글