[Storybook][MUI] 스토리북 실무에서 사용하기

Gyuwon Lee·2023년 1월 16일
4

디자인 시스템

목록 보기
2/6
post-thumbnail

본 글에서는 스토리북을 '스토리북 답게' 쓰기 위해, 여러 기능을 직접 활용해볼 것이다.

Storybook을 직접 사용해 보면 단순히 스토리를 작성해 UI 컴포넌트를 보여주는 것 자체는 어렵지 않은데 자잘한 충돌과 에러들이 잦아서 당황스러웠던 적이 많았다. 공식문서의 내용을 그대로 따라가기보다, 실무에 활용하기 위한 Storybook을 직접 작성해 보며 필요했던 기능들을 정리해보았다.

1. decorator

decorators란 컴포넌트 렌더링 시점에서 추가적인 스타일이나 provider 등을 wrapping하기 위해 사용되는 function이다. story / component / global 수준에서 각각 지정해줄 수 있다.

ThemeProvider로 preview 영역 감싸기

이 중 preview.ts의 decorators는 global decorator다. 보통 앱을 감싸야 하는 각종 Provider 컴포넌트들을 여기서 적용해준다. emotion(styled-components)과 MUI를 사용할 것이므로 ThemeProvider를 적용해 보자.

import { Decorator } from "@storybook/react";
import { ThemeProvider } from "@mui/material";

const previewDecorator: Decorator = (Story, context) => {
  return (
    <ThemeProvider theme="default">
      <Story {...context} />
    </ThemeProvider>
  );
};

decorator function은 위와 같은 형태로 작성한다. 인자로 Story와 context를 받는 함수인데, 여기서 Story는 우리가 작성한 *.stories.* 파일들이 보여질 Preview 영역이고 context는 대표적으로 globalTypes(context.globals) 값을 포함하고 있는 객체이다. (다른 속성으로 어떤 것들이 있는지는 공식문서 상 소개되어 있지 않다). 리턴하는 값은 리액트 엘리먼트 형태의 값이다.

중요한 것은, 초기 설치 시 preview 파일은 .ts 확장자이므로 리액트 엘리먼트를 감지하지 못한다는 점이다. preview.tsx로 파일 확장자를 수정하든가, decorator function을 관리하는 별도의 .tsx 파일을 만들어야 한다.

여기서 decorator function이 리액트 엘리먼트를 리턴할 뿐, 최종적으로 preview 파일은 decorators, globalTypes, parameters 값들을 리턴하는 곳이므로 .ts 확장자는 그대로 두고, decorators를 관리하는 별도 파일을 작성했다.

// preview-decorators.tsx
import React from "react";
import { Decorator } from "@storybook/react";
import { ThemeProvider } from "@mui/material";

const previewDecorator: Decorator = (Story, context) => {
  return (
    <ThemeProvider theme="default">
      <Story {...context} />
    </ThemeProvider>
  );
};

export default [previewDecorator];
// preview.ts
import type { Preview } from "@storybook/react";
import previewDecorators from "./preview-decorators";

const preview: Preview = {
  decorators: previewDecorators,
};

export default preview;

이제 스토리북 상에서 theme을 적용해볼 수 있게 되었다.

2. globalTypes

decorators는 모든 렌더링에 동일하게 적용되는 static한 값인 반면, globalTypes를 사용하면 동적으로 변경해보고 싶은 값을 제어할 수 있다. 대표적으로 light / dark 모드를 변경시키거나, locale을 변경해볼 때 사용된다.

globalTypes는 말 그대로 global한 값이므로, 오직 preview.ts 파일에서만 export 되어야 한다. decorators나 parameters 처럼 story나 컴포넌트 수준에서 설정할 수 없다는 뜻이다.

toolbar에 light / dark 모드 컨트롤 버튼 설정하기

먼저, preview.ts의 preview 객체 안에 globalTypes 속성을 추가한다.

// preview.ts
globalTypes: {
  mode: {
    description: "Mode for preview area",
    defaultValue: "light",
    toolbar: {
      title: "Mode",
      icon: "circlehollow",
      items: [
        { value: "light", icon: "starhollow", title: "light" },
        { value: "dark", icon: "star", title: "dark" },
      ],
      dynamicTitle: true,
    },
  },
},

light / dark 모드를 제어하는 값이므로, 'mode' 라는 key를 추가했다. globalTypes 객체 안의 key는 임의로 지을 수 있다. 이 key는 스토리북 url 상에 쿼리스트링으로 나타난다.

  • description: toolbar 상에서 해당 버튼에 커서를 올리면 나타나는 설명
  • defaultValue: 해당 key에 대한 기본 value
  • toolbar: toolbar 상에서 해당 value를 제어할 수 있도록 하는 configuration

globals 소비하기

이제 mode 라는 global을 만들어 toolbar를 통해 값을 제어할 수 있게 되었다. 그러나 toolbar에서 버튼을 조작해 보면 url 상의 값은 바뀌지만 preview 영역에는 아무런 변화도 없을 것이다. 버튼은 해당 global의 값을 변경해주기만 할 뿐, 변경된 값을 적용(소비)하는 부분은 아직 구현하지 않았기 때문이다.

여기서는 mode 값을 바탕으로 light / dark theme를 구분하여 ThemeProvider에 넘겨줄 것이다.

decorators 안에서 소비하기

공식문서에 따르면, globals는 decorators 안에서 소비되는 것이 권장된다. 앞서 우리는 decorator function을 통해 Story를 ThemeProvider로 감싸주었는데, 여기서 인자로 받았던 context.global에 접근하여 값을 가져올 수 있다.

// dark, light theme는 MUI theme configuration을 참고하여 구현한다.
// https://mui.com/material-ui/customization/dark-mode/

const previewDecorator: Decorator = (Story, context) => {
  const { mode } = context.globals;

  const theme = React.useMemo(() => {
    switch (mode) {
      case "light":
        return lightTheme;

      case "dark":
        return darkTheme;

      default:
        return lightTheme;
    }
  }, [mode]);

  return (
    <ThemeProvider theme={theme}>
      <Story />
    </ThemeProvider>
  );
};

앞서 작성한 decorators function을 위와 같이 수정했다.

  • context.globals 객체 중, 앞서 정의한 key에 접근한다.
  • 가져온 값을 바탕으로 불러올 theme가 동적으로 결정될 수 있게 switch문을 작성한다.
  • 동적으로 결정된 theme 값을 ThemeProvider의 theme에 넘겨준다.

toolbar를 조작하여 global의 값을 변경하고, 해당 값에 따라 동적으로 변화하는 theme를 적용할 수 있게 되었다.

story 안에서 소비하기

공식문서의 권장 사항은 아니지만, globals를 특정 story 안에서 소비해야 하는 경우가 생길 수도 있다. 예를 들어 인삿말을 보여주는 컴포넌트에서 locale에 따라 각기 다른 문구를 보여주어야 하는 경우를 생각해볼 수 있다.

const getCaptionForLocale = (locale) => {
  switch(locale) {
    case 'es': return 'Hola!';
    case 'fr': return 'Bonjour!';
    case 'kr': return '안녕하세요!';
    case 'zh': return '你好!';
    default:
      return 'Hello!';
  }
};

return <div>{getCaptionForLocale(locale)}</div>

물론 react-intl 등 라이브러리를 추가하면 컴포넌트 안에서 static하게 mapping하지 않아도 되지만, 아무튼 이처럼 story 안에서 global 값을 필요로 하는 경우가 있을 것이다. 이 경우, story render function의 두 번째 인자로 context를 받아 소비할 수 있다. (decorator function에서 두 번째 인자로 context를 받아온 것과 같다.)

render function에서 context를 받아 출력해 보면, 위처럼 여러 속성을 가진 객체가 나타난다. 저 중 globals 속성에 접근해서 값을 받아올 것이므로, 구조분해 할당을 활용해 아래와 같이 원하는 속성만 가져오자.

// CSF v2
const Default = (
  props: ButtonProps,
  { globals: { locale } },
) => <Button {...props} children={getCaptionForLocale(locale)} />;

// CSF v3
const Default = {
  render: (props: ButtonProps, { globals: { locale } }) => 
  	<Button {...props} children={getCaptionForLocale(locale)} />
};

이런 식으로 컴포넌트 안에서도 global 값을 사용해 동적으로 UI를 변경시킬 수 있다.

3. Controls 패널 (add-on)

스토리북을 사용하면 각 UI 컴포넌트를 독립적으로 렌더링하고, 동적으로 prop을 제어해 각기 다른 상태를 나타낼 수 있다.

여기서부터는 업데이트된 CSF v3 형식을 사용합니다. (Docs)

args 지정하기

보통은 template 컴포넌트를 만들어 놓고, args만 바꾸어 해당 컴포넌트를 재사용하는 경우가 많다.

import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "@mui/material";

type ButtonStory = StoryObj<typeof Button>;

export const Basic: ButtonStory = {
  args: {
    variant: "contained",
    children: "Button",
  },
};

export default {
  title: "Components/Button",
  component: Button,
} as Meta<typeof Button>;

하지만, args만 단독으로 사용하는 경우 해당 컴포넌트가 받는 전체 prop들이 전부 Controls에 표시되지 않고, args로 넘긴 prop들만 표시된다.

MUI의 Button 컴포넌트가 받을 수 있는 prop은 15개 이상인데, 위에서는 args로 넘긴 2개의 prop만 확인할 수 있다.

Storybook only adds props to the controls table that are explicitly declared in the component’s prop types or in the Story Args. (참고)

이는 스토리북이 컴포넌트의 prop에 명시적으로 지정된 type 및 args로 받은 값들만 Controls 패널에 표시해주기 때문이다.

스토리북은 codesandbox 같은 UI testing playground 라기 보다는 MUI Docs 처럼 고정된 상태의 UI를 나열한 문서라고 보는 편이 정확하다. 다시 말해, 한 개의 UI 컴포넌트에 대해 다양한 케이스의 디자인을 각각의 story로 나열하기 위해 탄생한 도구다. 스토리북 팀에 따르면 "The default export declares the component that you’re writing stories for" 라고 한다. 즉 각 *.stories.* 파일은 한 개의 UI 컴포넌트를 의미하고, 그 안의 여러 story들은 해당 컴포넌트가 가질 수 있는 여러가지 경우의 수들을 나타내는 것이다.

따라서 사실 이 글에서처럼 Control 패널에서 모든 prop들을 다 제어하려고 애쓰는 것은 스토리북의 사용 의도와는 조금 다른 방향일지도 모른다. 스토리북 팀의 디자인 시스템을 참고해봐도, label 등 일부 args만 조작할 수 있도록 해 두었을 뿐 여러 case 들은 각각의 story로 나타내고 있다.

하지만 회사에서 스토리북을 사용해 보니 UI가 제대로 표시되지 않을 때 컴포넌트 자체의 문제인지, 외부 코드 또는 API의 문제인지 확인하기 위해 참고하는 경우가 잦았다. 이 경우 내가 구현 중이던 컴포넌트와 동일한 case의 story를 찾는 것보다, 직접 Control 패널에서 컴포넌트를 조작해 문제가 발생한 case를 재현해 보는 것이 (당연히) 빠르다.

Controls에 propType 반영하기

전반적으로 스토리북 공식문서의 MUI integration recipe를 많이 참고했지만, 그대로 따라하면 막히는 부분들이 조금 생긴다.

// button.component.tsx

import React from 'react';
import { Button as MuiButton, ButtonProps as MuiButtonProps } from '@mui/material';

export const Button = (props: ButtonProps) => <MuiButton {...props}>{label}</MuiButton>;

공식문서에서 소개하는 기본적인 틀은 위와 같다. 앞서 말했듯 prop에 명시적으로 지정된 type 및 args로 받은 값들만 Controls 패널에 표시되기 때문에, story를 작성하며 props의 타입을 ButtonProps로 지정해주었다.

component metadata

문제는, 이렇게만 작성하면 여전히 control이 불가능할 뿐더러 위 예제에는 컴포넌트 메타데이터, 즉 default export가 없다는 것이다.

*.stories.* 파일은 한 개의 UI 컴포넌트를 의미하고, 파일의 default export는 내가 어떤 컴포넌트에 대한 story들을 작성하고 있는 것인지에 대한 정보(메타데이터)를 담고 있어야 한다. 따라서 props의 타입을 지정해 준 위 컴포넌트를 메타데이터의 component 값으로 지정해 주어야 스토리북이 지정한 propType대로 control 패널을 자동 생성할 수 있게 된다.

// button.component.tsx

import React from 'react';
import type { Meta } from "@storybook/react";
import { Button as MuiButton, ButtonProps as MuiButtonProps } from '@mui/material';

export const Button = (props: ButtonProps) => <MuiButton {...props}>{label}</MuiButton>;

export default {
  title: "Components/Button",
  component: Button,
} as Meta;

이처럼 Meta 객체에 component: Button 으로 지정되어 있어야 Control 패널 상에서 모든 props를 확인할 수 있다.

저는 CSF v3의 StoryObj를 쓰고 싶은데요?

스토리북 7.0부터는 Story 작성 방식이 함수가 아닌 객체 형식으로 바뀌었다.

// v6.5 or earlier
const Component = (args) => <Component {...args} />
Component.args = {...}
                  
// v7.0
const Component: StoryObj = {
  render: (args) => <Component {...args} />,
  args: {...}
}            

개인적으로는 이왕 CSF v3으로 마이그레이션 하는 김에, *.stories.* 파일 안에서는 StoryObj들만 관리하고 싶었다. 이런 경우 컴포넌트를 별도의 파일로 분리해서 관리하면 된다.

// Button.component.tsx
import React from "react";
import { Button, ButtonProps } from "@mui/material";

export const Default = (props: ButtonProps) => <Button {...props} />;

export default Default;
// Button.stories.tsx
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import Default from "./Button.component";

export const Basic: StoryObj = {
  args: {
    variant: "contained",
    children: "Button",
  },
};

export default {
  title: "Components/Button",
  component: Default,
} as Meta;

불필요한 prop 제거하기

이번엔 막상 propType대로 모든 props를 Controls 패널에 반영했더니, 사용하지 않는 prop이 많아 삭제하고 싶은 경우를 생각해보자.

예를 들어, MUI의 Button 컴포넌트에서 disableFocusRipple, disableRipple 같은 prop은 잘 건드리지 않는 경우가 많다. 보통 theme를 통해 일관된 스타일을 적용하므로 컴포넌트 수준에서 별도로 제어할 일은 잘 없기 때문이다. 이러한 경우 Omit 또는 Pick 타입을 활용해 간단하게 prop을 취사선택할 수 있다.

// Button.component.tsx
import React from "react";
import { Button, ButtonProps } from "@mui/material";

type OmitButtonProps = Omit<
  ButtonProps,
  "disableFocusRipple" | "disableRipple"
>;

export const Default = (props: OmitButtonProps) => <Button {...props} />;

export default Default;

Controls의 prop이 27개에서 25개로 줄어든 것을 확인할 수 있다.

4. Args

Args는 컴포넌트의 props 처럼 story의 render function이 인자로 받는 값이다. 리턴되는 컴포넌트의 prop, 스타일, input 값 등등을 args로 제어할 수 있으며, story / 컴포넌트 / global 수준에서 정의 가능하다.

prop 제어 방식 변경하기

컴포넌트 수준의 args를 활용하면, 해당 컴포넌트의 모든 story에서 특정 prop의 제어 방식을 변경할 수 있다. 예를 들어서, MUI Button 컴포넌트의 children prop은 ReactNode 타입의 값을 받는다. 그러나, Control 패널 상에서는 text input으로만 값을 입력할 수 없어 string이 아닌 다른 값(ex. <Typography children={...} />)을 테스트해볼 수가 없다.

Complex values such as JSX elements cannot be serialized to the manager (e.g., the Controls addon) or synced with the URL.

이런 경우 *.stories.* 파일의 default export 객체 안에서 argTypes 속성을 수정해주면 된다.

// Button.stories.tsx

export default {
  title: "Components/Button",
  component: Default,
  argTypes: {
    children: {
      control: { type: "radio" },
      options: ["text", "Typography"],
      mapping: {
        text: "Button",
        Typography: <Typography>Button</Typography>,
      },
    },
  },
} as Meta;

text input 대신, radio 버튼을 사용해 plain text와 Typography 컴포넌트를 각각 테스트해볼 수 있게 되었다.

text input도 유지하고 싶으면요?

위처럼 radio 버튼을 사용하면 여러 형태의 children 컴포넌트를 테스트해볼 수는 있어도 텍스트를 수정해볼 수 없다는 불편함이 생기는데, 앞서 ButtonProp을 Omit, Pick 했던 것처럼 이번에는 extend해서 새 prop을 추가하는 방식으로 해결할 수 있다.

// Button.component.tsx
interface ExtendButtonProps extends OmitButtonProps {
  label: string;
}

export const Default = ({ label = "Button", ...props }: ExtendButtonProps) => (
  <Button {...props}>{label}</Button>
);

5. Docs

스토리북 버전 7.0부터는 별도의 Docs 탭이 사라지고, Autodocs 또는 MDX를 사용해 Docs를 작성하게 되었다. Autodocs 방식이 간편하지만, 모든 컴포넌트에 동일한 layout의 Docs가 생성되므로 컴포넌트마다 layout을 달리 하고 싶다면 별도의 MDX 문서를 작성해야 한다.

MDX로 문서를 작성하는 것은 어렵지 않다. velog, notion 등 마크다운 에디터에서 흔히 작성해 온 대로 똑같이 문서를 작성하되, Doc Blocks에서 제공하는 스토리북 문서 용 컴포넌트들 또는 사용자가 개인적으로 정의해 둔 컴포넌트 (ex. Guidelines, Dos and Don'ts) 를 섞어서 작성할 수 있다.

Doc Blocks

스토리북은 원래 @Storybook/addon-docs 에 있던 Canvas, Meta 등등의 Docs 용 컴포넌트들을 7.0부터 @Storybook/blocks 에서 제공하기 시작했다. 제공하는 컴포넌트의 종류도 훨씬 다양해져서, 간편하게 Docs를 작성할 수 있게 되었다.

그전에는 Meta 컴포넌트의 title prop을 *.stories.* 파일의 default export 객체에 정의된 title과 일치시켜야 해당 컴포넌트에 대한 MDX 파일로 인식이 되었는데, 이제는 of prop을 사용해 간단하게 연결시킬 수 있게 되었다.

import * as ButtonStories from "./Button.stories";

// v6.5 or earlier
<Meta title="Components/Button" ... />

// v7.0
<Meta of={ButtonStories} />

이 Doc Block들은 모든 레벨의 parameters 객체 안에서 customize할 수 있다. 예를 들어, Controls 블록에서 특정 prop을 보여주지 않고 싶을 때 컴포넌트 수준의 parameters에서 해당 prop을 제거할 수 있다.

// Button.stories.tsx
export default {
  title: "Components/Button",
  component: Default,
  parameters: {
    docs: {
      // 반대로 include를 사용하면 원하는 prop만 보여줄 수도 있다.
      controls: { exclude: ['disableRipple'] },
    },
  },
  argTypes: {
    children: {
      control: { type: "radio" },
      options: ["text", "Typography"],
      mapping: {
        text: "Button",
        Typography: <Typography>Button</Typography>,
      },
    },
  },
} as Meta;

Auto-generated Docs Layout

MDX 문서를 하나하나 작성하기 귀찮다면, autodocs 태그를 사용해 문서를 자동 생성시킬 수도 있다.

// Button.stories.tsx
export default {
  title: "Components/Button",
  component: Default,
  tags: ['autodocs'],
} as Meta;

단, autodocs를 설정해 주었는데 동시에 해당 컴포넌트와 연결된 MDX 파일 역시 작성되어 있다면 에러가 발생한다. MDX 파일의 Meta 컴포넌트를 주석처리하거나, MDX 파일을 아예 지워주는 편이 좋다.

Doc Block들과 마찬가지로 autodocs의 layout 역시 parameters 객체 안에서 관리된다. 각 컴포넌트 안에서 설정해주어도 상관없지만, 공식문서에 따르면 일부 컴포넌트의 Docs layout만 수정할 거라면 해당 컴포넌트를 위한 별도의 MDX 문서를 작성하는 것을 권장하고 있으니 웬만하면 preview.ts의 parameters 객체 안에서 수정해 모든 autodocs에 일괄 적용되도록 하자.

// .storybook/preview.tsx
import { Preview } from '@storybook/react';
import { Title, Subtitle, Description, Primary, Controls, Stories } from '@storybook/blocks';

const preview: Preview = {
  parameters: {
    ...,
    docs: {
      page: () => (
        <>
          <Title />
          <Subtitle />
          <Description />
          <Primary />
          <Controls />
          <Stories />
        </>
      ),
    },
  },
};

export default preview;

MDX Template을 넣을 수도 있어요

한편, 위처럼 단순 Doc Blocks의 나열이 아니라 조금 더 복잡한 layout을 적용하고 싶다면 MDX로 별도의 템플릿을 만들어 넘겨줄 수도 있다. 위의 page 속성에 callback 대신 작성한 template MDX 문서를 넘겨주면 된다.

// preview.ts
import DocumentationTemplate from './DocumentationTemplate.mdx';

...
export default {
  parameters: {
    docs: {
      page: DocumentationTemplate,
    },
  },
};

Storybook을 직접 사용해 보면 단순히 스토리를 작성해 UI 컴포넌트를 보여주는 것 자체는 어렵지 않은데 자잘한 충돌과 에러들이 잦아서 당황스러웠던 적이 많았다. 공식문서의 내용을 그대로 따라가기보다, 실무에 활용하기 위한 Storybook을 직접 작성해 보며 필요했던 기능들을 정리해보았다.

이 글에 정리된 내용 역시 Storybook의 전부가 아니지만, 적어도 이 내용들을 응용하면

  • 전체적인 locale이나 background를 변경시키기
  • 컴포넌트와 stories 파일을 분리해서 관리하기
  • propType으로 Controls 패널 자동 생성하기
  • CSF v3 문법에 대응해서 story 작성하기
  • 원하는 layout 대로 Docs 작성하기

등등 구색을 갖춘 Storybook 문서를 작성하는 데는 큰 문제가 없을 것이라고 생각한다.

다만 앞으로도 공부해야 할 기능 역시 많이 남아 있다. 특히 MSW add-on을 활용하면 Storybook 실제 API 요청 시 컴포넌트가 어떻게 작동하는지를 story로 보여줄 수 있다고 하여 시도해볼 생각이다!

이 글에서 사용된 모든 코드와 Storybook 문서는 Github 에 정리해두었다.

profile
하루가 모여 역사가 된다

0개의 댓글