[Storybook] Storybook 200% 활용하기

Juno·2021년 8월 1일
164
post-thumbnail

🙌 들어가기

회사에 들어온 뒤 온보딩 과제를 끝내자마자, 첫 세미나를 맡게 되었습니다 🎉 그 내용을 블로그에도 공유해보려고 합니다.

최근에 마무리 했던 해커톤에서 Storybook을 처음으로 써봤는데 생각보다 너무 좋아보였고,
처음엔 잘 사용하다가... 스토리북을 제대로 공부해서 쓸 시간이 해커톤에선 부족하다 보니 뭔가 아쉬웠습니다.

그래서, 제대로 공부해보자는 욕심과 더불어 언젠간 사내에서도 쓰이게 될지도 모르기 때문에 주제로 선정하여 나름대로 열심히 준비해 보았습니당 😄

📔 Storybook 소개

Storybook is an open source tool for building UI components and pages in isolation. It streamlines UI development, testing, and documentation.

Storybook은 UI 개발환경이며, 동시에 UI 컴포넌트 Playground 라고 할 수 있습니다. UI를 컴포넌트 단위로 테스팅 해볼 수 있는 툴입니다.

React의 특징 중 하나가 잘 분리시킨 컴포넌트를 이용해 재 사용성을 높이는 것 임에 미루어 봤을 때, Storybook은 React와 꽤나 잘 어울리는 UI 테스팅 툴 이라고 생각합니다.

🙆🏻‍♂️ Storybook을 사용하는 이유

독립적인 환경에서 UI 컴포넌트를 개발할 수 있습니다.

컴포넌트 UI 개발을 하기 위해서 복잡한 데이터나 비즈니스 로직을 구축하지 않아도 됩니다.

특정한 스냅샷을 스토리로 만들고 테스트할 수 있습니다.

재사용을 위해 만들어진 컴포넌트들을 Story에서 조합하여 특정한 스냅샷을 만들어 개발이나 테스트 혹은 QA에 활용할 수 있습니다.
오직 테스트를 위해 특정 페이지에 만들고 지울 필요 없이, Story를 만들어 테스트할 수 있습니다.

UI 컴포넌트 라이브러리를 문서화 할 수도 있고, 디자인 시스템을 개발하기 위해 사용할 수도 있습니다.

추후에(?) 크리에이트립에서도 디자인 시스템을 개발한다면, Storybook을 활용할 수 있습니다 :)

+) 또한 Typescript를 지원하기 때문에, TS 프로젝트에 별도의 configuration이 필요하지 않다는 장점이 있습니다.
(Storybook has built-in Typescript support, so your Typescript project should work with zero configuration needed.)

⚙️ Storybook 세팅

Add Storybook

npx sb init

해당 명령어를 통해 Storybook을 간단하게 추가하실 수 있습니다. 정말 간단하지 않나요?! 하지만, 이 CLI는 이미 세팅이 되어있는 framework에만 정상적으로 동작합니다. (예시로 CRA가 있겠네요.)

그럼 다음과 같이 루트 경로에 .storybook 폴더에 main.jspreview.js 가 생깁니다.

main.js

const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');

module.exports = {
  stories: ['../**/*.stories.mdx', '../**/*.stories.@(js|jsx|ts|tsx)'],
  addons: ['@storybook/addon-links', '@storybook/addon-essentials'],
  webpackFinal: async (config) => {
    config.resolve.plugins.push(new TsconfigPathsPlugin({}));
    return config;
  },
};

main.js 에는 storybook을 위한 config 설정들이 담겨있습니다. npx sb init 을 통해서 기본으로 설정되는 storiesaddons 세팅과 (stories에서 story 파일이 프로젝트 내 어디에 어떤 형식의 파일이 위치하는지를 명시해 주어야 storybook 실행 시 정상적으로 불러올 수 있습니다.)

추가적으로 해당 레포에선 절대경로로 import 하고 있기 때문에 TsconfigPathPlugin 을 설치하여 Story에서 import에 문제가 없도록 하였습니다.

preview.js

import { ThemeProvider } from 'styled-components';
import { GlobalStyle } from '../pages/_app';
import { theme } from '../styles/theme';

export const parameters = {
  actions: { argTypesRegex: '^on[A-Z].*' },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/,
    },
  },
};

export const decorators = [
  (Story, context) => (
    <ThemeProvider theme={theme}>
      <GlobalStyle />
      <Story {...context} />
    </ThemeProvider>
  ),
];

preview.js 에는 해당 프로젝트의 모든 Story에 global하게 적용될 포맷을 세팅하는 곳 입니다. 후에 나오겠지만, parameter와 decorater는 Story의 프로퍼티, 여기선 포맷에 해당합니다.

현재 프로젝트에선 Styled-compoents를 이용해 세팅되어 있으므로 ThemeProvider를 통해 감싸주고 GlobalStyle도 적용시켜 주었습니다.

parameters 에 적용된 속성은 npx sb init 을 통해 기본적으로 세팅된 값 입니다. 여기선 actionscontrols 가 있는데 actions은 뒤에서 언급하고 여기선 controls에 대해서만 언급하고 넘어가겠습니다.

controls

Storybook의 controls는 개발자가 코드를 따로 변경하지 않고 Storybook에서 arguments를 동적으로 바꿔가며 인터렉션할 수 있도록 도와주는 기능입니다. Button을 예시로 든다면, 코드를 변경하지 않고 color를 바꿔본다던가, 텍스트를 바꿔본다던가 등등을 control 탭에서 손쉽게 바꿔보며 테스트할 수 있습니다.

https://storybook.js.org/9d2e1f29cfb010e3aae6cd76547c4bab/addon-controls-optimized.mp4

요렇게 말이죠!!

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

그래서 이 코드가 뜻하는 건, 해당 데이터 타입을 가진 속성을 만났을 때, regex를 통해 데이터 타입에 따라 storybook은 이들을 적절하게 테스트 할 수 있도록 매칭해 줄 수 있다는 뜻 입니다.

적절하게 테스트 한다는 뜻은, color를 string 값을 바꿔가면서 테스트할수도 있지만, 이미 정의된 primary primaryOutline flat 등을 라디오 버튼을 통해 선택하는 것이 좀 더 정확하고 직관적일 것 입니다.

위의 두 데이터 타입은 각각 color picker UI와 date picker UI 를 통해 control 할 수 있도록 세팅한 것 입니다.

자, 이제 이러면 최소한의 세팅은 끝이 났습니다. 바로 실행시키면서 Story를 작성하는 방법을 알아보겠습니다 😁

npm run storybook # run on localhost:6006

✍🏻 Story 작성하기

Story가 위치할 장소

Story는 컴포넌트가 위치하는 폴더 안에 같이 위치시키는 것이 일반적이며, Story 파일은 development-only 로써 production bundle 에는 포함되지 않습니다.

# Story는 다음과 같은 파일 형식을 가질 수 있습니다.
PopConfirm.js | ts | jsx | tsx
PopConfirm.stories.js | ts | jsx | tsx

Component Story Format (CSF)의 형식으로 작성하기

CSF란 스토리북에서 사용하는 포맷으로 version 5.2 이후로 추천하는 작성 포맷입니다. story들은 ES6 모듈 형태로 정의되며 모든 storybook 파일은 default exportnamed export 를 이용하여 작성할 수 있습니다.

(참고) version 5.2 이하에서의 방식

    import React from 'react';
    import { storiesOf } from '@storybook/react';
    import { action } from '@storybook/addon-actions';
    import Button from '../components/Button';

    storiesOf('Button', module)
      .add('with text', () => (
        <Button onClick={action('clicked')}>Hello Button</Button>
      ))
      .add('with some emoji', () => (
        <Button onClick={action('clicked')}>
          <span role="img" aria-label="so cool">
            😀 😎 👍 💯
          </span>
        </Button>
      ));

storiesOf 라는 API를 사용하고 이는 지금도 동작하지만, CSF 방식이 더 직관적이며 공식문서에서도 권장하는 방식입니다 :)

default export

import MyComponent from './MyComponent';

export default {
  title: 'Path/To/MyComponent',
  component: MyComponent,
  decorators: [ ... ],
  parameters: { ... }
}

export default { } 를 활용하여 어떤 컴포넌트의 Story인지, 그리고 어떤 설정으로 렌더링 할지를 정의합니다.

named export

// MyComponent.story.js | MyComponent.story.jsx | MyComponent.story.ts | MyComponent.story.tsx

import React from 'react';

import MyComponent from './MyComponent';

export default {
  title: 'Path/To/MyComponent',
  component: MyComponent,
};

export const Basic = () => <MyComponent />;
export const WithProp = () => <MyComponent prop="value" />;

export const Name = ... 를 사용하여 새로운 Story를 만들 수 있습니다.

Story의 주요 format(속성)

🏡 Storybook UI의 구조(Manager: Sidebar / Preview: Canvas)

title

스토리북의 Manager Area에 에 폴더 구조를 통해 나타낼 수 있습니다. '/' 를 통해 계층을 구분할 수 있습니다.

component

The component field is optional (but encouraged!), and is used by addons for automatic prop table generation and display of other component metadata. title should be unique, i.e. not re-used across files.

어떤 컴포넌트를 Story에 문서화 할지 명시적으로 적는 부분입니다. 위의 설명과 같이 옵셔널이지만, addon 에 사용되므로 적어주는 것이 권장사항 입니다.

👨🏻‍💻 다시, Story를 작성하는 방법!

// Button.stories.ts | Button.stories.tsx

export const Primary: React.VFC<{}> = () => <Button background="#ff0" label="Button" />;
export const Secondary: React.VFC<{}> = () => <Button background="#ff0" label="😄👍😍💯" />;
export const Tertiary: React.VFC<{}> = () => <Button background="#ff0" label="📚📕📈🤓" />;

story는 다음과 같이 하나의 컴포넌트에 여러개의 story를 가질 수 있습니다.

위의 예시는 Button 컴포넌트에 다른 argument만 다르게 하여 반복적으로 선언한 모습입니다.

이때, args 를 이용하여 argument만 다르게 story를 재사용 할 수 있습니다.

Args

Story는 렌더링된 UI 컴포넌트의 state를 캡쳐하여 어떻게 컴포넌트가 렌더링되는지 보여주는 Function 입니다. 이때, Story는 React의 props와 비슷한 느낌으로 arguments를 args 라는 이름으로 가지고 있습니다.

다음은 args를 다르게 부여해서 여러개의 Story를 재사용 하는 코드입니다.

// Button.stories.ts | Button.stories.tsx

//👇 We create a “template” of how args map to rendering
const Template: Story<ButtonProps> = (args) => <Button {...args} />;

//👇 Each story then reuses that template
export const Primary = Template.bind({});
Primary.args = { background: '#ff0', label: 'Button' };

export const Secondary = Template.bind({});
Secondary.args = { ...Primary.args, label: '😄👍😍💯' };

export const Tertiary = Template.bind({});
Tertiary.args = { ...Primary.args, label: '📚📕📈🤓' };

먼저 Template.bind({}) 구문을 통해 만들어진 Template 이라는 기본적인 틀, 즉 템플릿을 만들면 거기에 args를 할당할 수 있게 되고,

Template로 만든 story에 args만 다르게 해서 여러개의 story를 렌더링 할 수 있습니다.

(여기서의 ...은 ES6의 spread 구문과 유사하다고 보면 될 것 같습니당)

여기서 args를 story의 기본값으로 설정하여 화면에 렌더링한 것이고, storybook의 controls 탭에서 args의 값을 자유롭게 수정할 수 있습니다.

https://storybook.js.org/ab451447f5f33717ed2ae14567375bb5/addon-controls-demo-optimized.mp4

또한 콜백함수와 같은 함수의 동작들도 actions 탭에서 확인하실 수 있습니다.

preview.js

export const parameters = {
  actions: { argTypesRegex: '^on.*' },
};

여러가지 방법이 있지만, Global 하게 argType에 on 으로 시작하는 이벤트 핸들러 함수들을 모두 허용하는 정규식을 적어주면, Action 탭에서 이벤트가 발생하는 것을 감지할 수 있습니다.

https://storybook.js.org/b0366940cf7195b6d5b646c6105c217c/addon-actions-optimized.mp4

Decorator

데코레이터는 기능적으로 추가적인 렌더링을 통해 감쌀 수 있는 방법입니다.

일반적으로 아래의 두 가지 경우에 사용됩니다.

  1. 추가적으로 컴포넌트를 새로운 마크업으로 감싸기 위해 사용 (Component decorators)
// YourComponent.stories.ts | YourComponent.stories.tsx

import { Meta } from '@storybook/react';

export default {
  component: YourComponent,
  decorators: [
    (Story) => (
      <div style={{ margin: '3em' }}>
        <Story/>
      </div>
    ),
  ],
} as Meta;

기본적으로 작성한 스토리는 왼쪽 상단에 붙어서 렌더링 됩니다.
이 렌더링 된 부분을 추가적인 마크업을 작성하고 데코레이터를 이용해 감싸게 된다면 다음처럼 볼 수 있게 됩니다.

  1. Context Provider로 감싸야 하는 경우 사용 (Global decorators)
// .storybook/preview.js

import React from 'react';

import { ThemeProvider } from 'styled-components';

export const decorators = [
  (Story) => (
    <ThemeProvider theme="default">
      <Story />
    </ThemeProvider>
  ),
];

 몇몇 라이브러리는 계층적으로 Provider로 감싸야 정상적으로 작동합니다. 대표적인 예시로 styled-componentstheme을 사용할 경우 ThemeProvier 로 컴포넌트를 감싸주어야 합니다.
이 때 preview.js 에서 decorator 를 사용하게 될 경우 아래의 세 가지 레벨 중 3번째에 해당하게 됩니다.

주로 사용하는 경우를 보여드렸고, 좀 더 자세히 설명드리면, 데코레이터는 다음의 세 가지 레벨을 가집니다.

Decorators에는 총 3가지 레벨이 존재합니다.

1. Story decorators
2. Component decorators
3. Global decorators

  • 말 그대로 1번은 하나의 Story 범위에만 적용되는 것이고, 2번은 해당 stories.tsx 파일의 범위에만 적용되는 것이며 3번은 모든 stories.tsx 에 적용됩니다. 예시로 1번만 다시 확인하겠습니다.

Story decorators

// Story decorators
export const Primary = …
Primary.decorators = [(Story) => <div style={{ margin: '3em' }}><Story/></div>];

Parameters

parameter는 story의 정적인 메타데이터를 뜻하며 주로 storybook의 feature와 addon을 조작합니다.

공식문서의 예시는, parameters.backgrounds 를 통해 story의 backgrounds에 red 와 green color의 정적인 데이터를 부여하고 있습니다.

Essential addons

background와 같은 addon을 parameter를 통해 적용할 수 있습니다!

Parameters 에도 총 3가지 레벨이 존재합니다.

1. Story parameters
2. Component parameters
3. Global parameters

위의 툴바에서 backgrounds를 변경할 수 있고, 이는 parameter를 통해 정의하였습니다

Story parameters

// Button.stories.js | Button.stories.ts | Button.stories.jsx | Button.stories.tsx 

export const Primary = Template.bind({});
Primary.args = {
  primary: true,
  label: 'Button',
};
Primary.parameters = {
  backgrounds: {
    values: [
      { name: 'red', value: '#f00' },
      { name: 'green', value: '#0f0' },
    ],
  },
};
  • 이는 story 즉, local 단계에 해당합니다. Primary 라는 story에만 parameter가 적용됩니다.

Component parameters

// Button.stories.js | Button.stories.ts | Button.stories.jsx | Button.stories.tsx 

import Button from './Button';

export default {
  title: 'Button',
  component: Button,
  parameters: {
    backgrounds: {
      values: [
        { name: 'red', value: '#f00' },
        { name: 'green', value: '#0f0' },
      ],
    },
  },
};
  • 이는 component 단계로, 해당 stories.tsx 파일에 속한 모든 story에 parameter가 적용됩니다.

Global parameters

// .storybook/preview.js

export const parameters = {
  backgrounds: {
    values: [
      { name: 'red', value: '#f00' },
      { name: 'green', value: '#0f0' },
    ],
  },
};
  • 이는 global 단계로, decorator와 마찬가지로 preview.js 에 정의합니다. storybook 모든 파일에 parameter가 적용됩니다.

💡 Rules of parameter inheritance

css의 선택자와 개념이 비슷합니다. 만약 다음 세개의 선택자에 중복으로 css가 정의되었다면,

태그 선택자 < 클래스 선택자 < id 선택자 의 순서로 우선순위를 갖는 것 처럼

parameter와 decorator 모두 global < components < story 의 순서로 우선순위를 갖습니다.

Rename stories

말 그대로 story의 이름을 바꿀 수 있습니다. storyName 이라는 프로퍼티에 새로운 이름을 할당해 줍니다.

// Button.stories.ts | Button.stories.tsx

import React from 'react';

import { Story } from '@storybook/react';

import { Button, ButtonProps } from './Button';

export const Primary: Story = () => <Button primary>Button</Button>;

Primary.storyName = 'I am the primary';

Addons

Storybook에는 addon 이라는 plugin 시스템이 있습니다. 공식적으로 지원하는 addon 뿐만 아니라 여러 오픈소스 addon도 존재합니다. 여러가지 addon을 register하여 Storybook의 기본 기능에 추가기능을 붙일 수 있습니다.

Parameters에서 잠깐 소개드렸던 Background와 Action도 이에 해당하고, 반응형 개발을 위한 Viewport도 있으며 라우팅을 위한 react-router도 존재합니다.(next.js router도 물론 오픈소스로 개발되어 있습니다!)

데이터 연결

Storybook은 사실 빈 껍데기 뿐만 아니라, 데이터를 받아와서 보여줄 수도 있습니다.

mocking

import React from 'react';
import { action } from '@storybook/addon-actions';
import { Provider } from 'react-redux';
import Container from './Container';

// mock store
const store = {
  getState: () => {
    return {
      todos: [
        {
          id: 1,
          text: 'Do Something',
          done: false
        }
      ]
    };
  },
  subscribe: () => {},
  dispatch: action('disptach')
};

const withReduxMockStore = (story: () => JSX.Element) => (
  <Provider store={store}>{story()}</Provider>
);

export default {
  title: 'components|Container',
  component: Container,
  decorators: [withReduxMockStore]
};

export const sample = () => <Container />

리덕스를 사용 할 때 컨테이너 컴포넌트의 스토리를 작성 할 경우, 다음과 같이 Provider 에서 mock store를 사용하는 decorator를 직접 만들어서 적용하시면 액션이 디스패치 될 때 Storybook의 Actions 탭에서 확인 할 수 있습니다.

데이터 연결하기

그 외에도 Loaders 라고 해서 api 콜을 통해 data를 story나 decorators로 받아올 수 있는 비동기 함수가 있는데, 이는 아직 experimental 이므로 링크만 소개드리고 마무리 하겠습니다.
https://storybook.js.org/docs/react/writing-stories/loaders

🙌 마치며

 그 동안은 주로 구글링을 할 때 번역된 공식문서를 1순위, 그리고 잘 쓰여진 블로그와 영문 공식문서 순서로 정보를 찾았는데, 이전에 프로젝트를 하면서 recoil을 적용함에 있어 제대로 정리된 블로그가 없어 공식문서만 찾아보며 정리했던 기억이 있습니다.

 이번에도 마찬가지로 정확한 정보 전달이 가장 중요한 만큼 좀 더 시간이 걸리더라도 공식문서를 요리조리 뜯어보고 거기에 한글로 번역이 어려운 부분에 대한 것만 다른 블로그를 참고하는 방식으로 공부했습니다. 입사하고 나서 다른 코드들을 많이 읽어보고 어려운(?) 표현들도 조금씩 접하게 되어 영어를 해석하지 못해도 코드를 조금씩 더 읽을 수 있게 되는 것 같아서 뿌듯합니다. 영어라서 무조건 피하지 않고, 정확한 정보를 얻기 위해 공식문서에 더욱 익숙해지도록 노력해야겠습니다.

긴 글 읽어주셔서 감사합니다 🙏

Reference

⭐️ Storybook Reference

profile
사실은 내가 보려고 기록한 것 😆

10개의 댓글

comment-user-thumbnail
2021년 8월 5일

유용한 정보 감사합니다👍 잘 사용해볼게요!!

답글 달기
comment-user-thumbnail
2021년 8월 5일

답글 달기
comment-user-thumbnail
2021년 8월 6일

답글 달기
comment-user-thumbnail
2021년 8월 7일

좋은 정보 감사합니다 🙌🏻

답글 달기
comment-user-thumbnail
2021년 8월 8일

d

답글 달기
comment-user-thumbnail
2021년 8월 10일

잘보구가영

답글 달기
comment-user-thumbnail
2021년 8월 11일

너무 멋져요

답글 달기
comment-user-thumbnail
2021년 10월 15일

스토리북 처음 입문하는데 정말 많은 도움이되었어요! 감사합니다!! 저도 공식문서랑 친해져야되는데 쉽지 않네요 😂😂

답글 달기
comment-user-thumbnail
2022년 4월 26일

감사합니다~!

답글 달기
comment-user-thumbnail
2023년 4월 30일

정보 글 감사합니다. 스토리북 사용에 대해 좀 더 알게 되었어요 👍

답글 달기