1. Storybook을 쓰면 좋은 상황1-1. 컴포넌트의 상태가 다양할 때 좋아요1-2. 재현 과정이 복잡할 때 좋아요1-3. 의도치 않은 화면의 변경을 알려줘요2. 스토리 작성 예시2-1. 가장 기본적인 Story 파일 형태2-2. Button 컴포넌트 예시2-2-1. 버튼의 각 variant를 모두 문서화하기2-2-2. 버튼의 자식으로 Text(children)를 렌더링하기2-3. Checkbox 컴포넌트 예시2-3-1. 체크박스 클릭 시 화면에 반영 되게 하기2-3-2. 체크박스의 배경으로 Modal을 쓰기 (Modal 속에서 렌더링하기)2-3-3. 자동으로 체크 됐다가 체크 해제하는 스토리 작성하기2-4. [WIP] TodoList 페이지 예시2-4-1. Storybook에서 페이지를 만들 때 주의사항2-4-3. 페이지 예시3. Storybook 기본 설정 방법3-1. 기본 설정3-1-1. TailwindCSS 설정3-1-2. 컴포넌트 분류 설정3-1-3. control에서 Color, Date Picker 설정3-2. 페이지 컴포넌트 스토리 기본 설정3-2-1. Router에 의존하는 컴포넌트를 위해 router 애드온 사용하기3-2-2. API mock을 할 수 있도록 msw 애드온 설정하기3-2-3. React-query에 의존하는 컴포넌트를 위해 QueryProvider 렌더링하기3-2-4. localstorage에 의존하는 컴포넌트를 위해 Story마다 설정/초기화하기3-2-5. pageParam에 의존하는 컴포넌트를 위해 주소 설정하기3-3. Chromatic으로 시각 회귀 테스트하기3-4. Chromatic으로 스토리북 배포하기3-4-1. Chromatic에 가입 후 repo를 연결해요.3-4-2. GitHub Actions로 PR 푸시 시 자동으로 빌드해요
1. Storybook을 쓰면 좋은 상황
1-1. 컴포넌트의 상태가 다양할 때 좋아요
공통 컴포넌트 혹은 페이지의 상태를 문서화할 수 있어요.
- 버튼 컴포넌트의 유형 별로 옵션만 선택하면 쉽게 확인할 수 있어요.
- 게시글 목록이 비었을 때의 화면을 쉽게 확인할 수 있어요.
1-2. 재현 과정이 복잡할 때 좋아요
특정 상태의 화면을 재현하기 불편한 화면이 있을 거에요.
- 로그인을 하고 글을 등록하고 난 후의 페이지 등이 그렇죠.
- Storybook 없이 개발한다면 코드 변경 후 매번 수동으로 테스트를 해야 돼요.
- Storybook으로 이를 자동화하면 이후에는 클릭 한 번으로 확인할 수 있어요.
- 미리 <로그인 된 사용자>, <글을 등록한 상태>로 상태를 구성해놓을 수 있어요.
- 그러면 개발자는 해당 페이지 상태를 조회만으로 확인할 수 있어요.
1-3. 의도치 않은 화면의 변경을 알려줘요
Pixel 단위로 캡쳐한 후 자동으로 비교해줘요.
- 의도하지 않은 화면 변경이 발생했을 때 경고를 받을 수 있어요.
2. 스토리 작성 예시
2-1. 가장 기본적인 Story 파일 형태
Story 파일은 컴포넌트마다 하나씩 작성하게 돼요.
Meta
타입의 객체를 default export 해야 돼요.meta
는component
를 필수값으로 가져요.
Story
타입의 객체를 최소 하나 이상 Named export 해야 돼요.
import { Button } from '@/components/common/Button'; import type { Meta, StoryObj } from '@storybook/react'; const meta: Meta<typeof Button> = { component: Button, }; export default meta; type Story = StoryObj<typeof Button>; export const Primary: Story = { args: { variant: 'primary', }, };
2-2. Button 컴포넌트 예시
2-2-1. 버튼의 각 variant를 모두 문서화하기
방법은 2가지가 있어요.
- 수동으로 variant마다 Story를 만들기
- 자동으로 만드는 기능은 없어요.
export const Primary: Story = { args: { variant: 'primary', }, }; export const Outline: Story = { args: { variant: 'outline', }, };
- 자동 생성되는 controls 옵션으로 떼우기
- 옵션은 Prop Type을 읽어 자동으로 생성돼요.
- variant 변경 시 문서도 함께 변경해줄 수밖에 필요가 없어요.
// 기본 값을 지정하는 경우 export const DefaultTemplate: Story = { args: { variant: 'primary', size: 'default', children: 'Button Text', }, }; // 기본 값을 지정하지 않는 경우는 생략해도 돼요. export const DefaultTemplate = {};
2-2-2. 버튼의 자식으로 Text(children)를 렌더링하기
- Button의 Text처럼 렌더링을 위해 기본 Prop이 필요할 때가 있어요.
- 스토리 파일 내의 모든 스토리 객체들의 공통 prop으로 정의할 수 있어요.
meta.args
혹은meta.render
를 작성하면 돼요.- meta.args를 사용하는 방법
const meta: Meta<typeof Button> = { component: Button, args: { children: 'Hello World', }, };
const meta: Meta<typeof Button> = { component: Button, args: { render: (args) => <Button {...args}>Hello World</Button> }, };
2-3. Checkbox 컴포넌트 예시
2-3-1. 체크박스 클릭 시 화면에 반영 되게 하기
Checkbox 컴포넌트가 체크 여부를 변경하려면
props
로 주입 받아야 해요.- 이 경우 상태를 관리할 상위 컴포넌트가 필요해요.
- 상위 컴포넌트를 정의해줄 수 있어요.
meta
객체 혹은 각Story
객체의render
필드에 정의해줄 수 있어요.- 공통으로 쓰려면
meta
객체에, 개별로 다른 상위 컴포넌트가 필요하면Story
에 정의하면 돼요.
const meta: Meta<typeof Checkbox> = { // !!!주의사항: args를 받지 않으면 controls 자동 타입 추론이 안 돼요!!! render: function Container(args: CheckboxProps) { const [, updateArgs] = useArgs<CheckboxProps>(); function handleChange() { updateArgs({ checked: !args.checked }); } return <Checkbox {...args} onChange={handleChange} />; }, };
2-3-2. 체크박스의 배경으로 Modal을 쓰기 (Modal 속에서 렌더링하기)
2가지 방법이 있어요.
meta.decorators
필드- react-router의
<Outlet />
처럼 쓸 수 있는<Story />
를 제공해줘요. - 단순한 Element를 반환하면 돼요.
const meta: Meta<typeof Checkbox> = { decorators: [ Story => ( <div className='rounded-lg border-2 border-gray-200 p-8'> <Story /> </div> ), ], };
meta.render
필드render
를 사용하는 것도 좋겠지만,render
는 너무 강력하기 때문에 의도를 알기 위해서는 코드를 읽어야 해요.
2-3-3. 자동으로 체크 됐다가 체크 해제하는 스토리 작성하기
가만히 있으면 알아서 상태를 바꿔주는 스토리를 만들 수도 있어요.
- Story 객체의
play
필드에 정의하면 돼요. - testing-library API를 활용하는 것 외에 특별한 지식은 필요 없어요.
- 이름을 짓기가 어려울 수도 있는데, 이 경우 Story 객체의
name
필드로 사이드바에 표시되는 이름을 설정할 수 있어요.
import { userEvent, within } from '@storybook/testing-library'; export const PlayingChecky: Story = { name: '체크 후 체크 취소', args: { checked: false, label: 'label test', name: 'name', }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const checkbox = canvas.getByLabelText('label test'); await userEvent.click(checkbox, { delay: 500, }); await userEvent.click(checkbox, { delay: 500, }); }, };
2-4. [WIP] TodoList 페이지 예시
2-4-1. Storybook에서 페이지를 만들 때 주의사항
- 페이지는 외부 의존성을 곧장 활용해서, mock을 하려면 API 수준에서 해야 돼요.
- 실제 개발 서버에 영향이 없으면서도 CRUD를 하려면 msw가 필요해요.
- mock할 대상은 크게 API, localstorage, router인 것 같아요.
- API
/me
API → ‘나’를 가리키는 사용자- 리소스 API → ‘나’를 기준으로 데이터를 조회하고 변경해요.
- react-query의 queryClient는 정품 (?)으로 global decorator 설정해도 좋을 것 같아요.
- localstorage
- 의존성이 있다면 꼭 mock해야 돼요.
- CI 환경에 localstorage가 없어요.
- 브라우저 환경에서 실제 localstorage가 조작되면 안돼요.
- router
<Link />
등을 쓰는 경우 React-router의 Router 하위 컴포넌트로 렌더링돼야 해요.- react-router Addon을 설치하면 렌더링할 페이지를
/
로 렌더링할 수 있어요. - 특정 페이지만 문서화하는 거라면 추가적인 라우팅 기능이 필요하진 않을 거에요.
- 자세한 기능은 해당 addon 문서를 참고해주세요.
import { reactRouterParameters, withRouter } from 'storybook-addon-react-router-v6'; const preview: Preview = { decorators: [withRouter], parameters: { // .... reactRouter: reactRouterParameters({ routing: { useStoryElement: true, } }) }, };
2-4-3. 페이지 예시
목록 조회 페이지가 있다면 다음의 상태를 Storybook으로 재현하면 좋을 것 같아요.
- 목록을 로딩 중인 상태 화면
- 조회에 실패한 화면
- 조회 결과가 비었을 때의 화면
- 조회가 성공한 화면
3. Storybook 기본 설정 방법
3-1. 기본 설정
3-1-1. TailwindCSS 설정
Tailwind 사용 시에는 Tailwind CSS 파일을 import 해야 해요.
.storybook/preview.ts
에 설정하는 내용이에요.
/** * Tailwind 사용 시 Tailwind CSS를 preview에서 import 해야 한다. * @see https://storybook.js.org/recipes/tailwindcss */ import '../src/index.css';
/* Tailwind CSS 파일 내용 */ @tailwind base; @tailwind components; @tailwind utilities; /* ... */
3-1-2. 컴포넌트 분류 설정
- 스토리를 그냥 만들면
STORIES
하위의 최상위 요소가 돼요.
- 상위 분류를 만드려면
meta.title
을 직접 지정해서 가능해요. /
를 경계로 각 분류가 생성돼요.
const meta: Meta<typeof Button> = { title: 'common/Button', component: Button, };
3-1-3. control에서 Color, Date Picker 설정
특정 prop의 control에 Color Picker, Date Picker를 적용할지 여부를 설정할 수 있어요.
.storybook/preview.ts
에 설정하는 내용이에요.
parameters: { controls: { matchers: { color: /(background|color)$/i, date: /Date$/i, }, }, },
3-2. 페이지 컴포넌트 스토리 기본 설정
3-2-1. Router에 의존하는 컴포넌트를 위해 router 애드온 사용하기
<Link />
등을 쓰는 경우 React-router의 Router 하위 컴포넌트로 렌더링돼야 해요.- 그렇지 않으면 아래와 같은 오류가 발생해요.
- react-router Addon을 설치하면 렌더링할 페이지를
/
로 렌더링할 수 있어요. - 특정 페이지만 문서화하는 거라면 추가적인 라우팅 기능이 필요하진 않을 거에요.
import { reactRouterParameters, withRouter } from 'storybook-addon-react-router-v6'; const preview: Preview = { decorators: [withRouter], parameters: { // .... reactRouter: reactRouterParameters({ routing: { useStoryElement: true, } }) }, };
3-2-2. API mock을 할 수 있도록 msw 애드온 설정하기
페이지에서 API 요청을 하는 경우 API 응답을 mocking해야 돼요.
- 실제 API 요청을 하게 되면, 스토리가 재현하는 화면이 계속 달라지기 때문이에요.
msw와 msw-storybook-addon을 설치해야 돼요.
pnpm install -D msw msw-storybook-addon@2.0.0-beta.1 # Vite 사용 시 오류가 있어서 2.0 버전을 받아야 돼요.
.storybook/preview.ts
에 아래 내용을 추가해주세요.import { initialize as initializeMSW, mswLoader } from 'msw-storybook-addon'; // msw의 worker.start 옵션을 전달할 수 있어요. /* 예시: initializeMSW({ onUnhandledRequest: ignoreDevResources, }); */ initializeMSW(); const preview: Preview = { // ... loaders: [mswLoader], }; export default preview;
msw handler들은 각 스토리 파일의
meta.parameters.msw.handlers
내에 위치시키면 돼요.- 각 스토리 별로
parameters
를 별도로 정의해도 돼요.
import { HttpResponse, http } from 'msw'; const meta: Meta<typeof HomePage> = { parameters: { msw: { handlers: [ http.get('/api/echo', () => HttpResponse.json('hello from server')), ], }, }, };
실제 예시
- 페이지에서 fetch 요청을 하는 코드에요.
export const HomePage = () => { const [response, setResponse] = useState(''); useEffect(() => { (async () => { const apiResponse = await fetch('/api/echo', { method: 'GET', headers: { 'Content-Type': 'application/json', }, }).then(response => response.json() as Promise<string>); setResponse(apiResponse); })(); }, []); return (/* ... */); };
아래와 같이 API mock이 됨을 확인할 수 있어요.
3-2-3. React-query에 의존하는 컴포넌트를 위해 QueryProvider 렌더링하기
아래와 같이 react-query를 사용하는 페이지의 경우 QueryProvider가 부모로 렌더링되어야 해요.
import { useQuery } from '@tanstack/react-query'; const fetchApiResponse = () => fetch('/api/echo', { method: 'GET', headers: { 'Content-Type': 'application/json', }, }).then(response => response.json() as Promise<string>); export const HomePage = () => { const { data, isFetching } = useQuery({ queryFn: fetchApiResponse, queryKey: ['echo'], }); return (/* ... */); };
.storybook/preview.tsx
에 다음 내용을 추가하면 돼요.import React from 'react'; import { ReactQueryProvider } from '../src/ReactQueryProvider'; const preview: Preview = { decorators: [ (Story) => <ReactQueryProvider><Story /></ReactQueryProvider> ], }; export default preview;
ReactQueryProvider
는 queryClient
의 중복 정의를 막기 위한 것으로 아래와 같이 작성된 코드에요.import { PropsWithChildren } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, gcTime: 1000 * 60 * 5, retry: 0, refetchOnWindowFocus: false, }, }, }); export const ReactQueryProvider = ({ children }: PropsWithChildren) => { return ( <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ); };
3-2-4. localstorage에 의존하는 컴포넌트를 위해 Story마다 설정/초기화하기
localstorage 상태에 의존하는 컴포넌트의 경우 각 스토리마다
loaders
필드와 decorators
필드에 함수를 등록해 localstorage의 값을 변경하거나 초기화하는 코드를 추가해야 돼요.localstorage의 상태를 Storybook 탭에 보여주는 애드온도 있어요
3-2-5. pageParam에 의존하는 컴포넌트를 위해 주소 설정하기
예시:
:boardGameId
라는 pathParam을 사용할 때의 설정const meta: Meta<typeof BoardGameDetailPage> = { component: BoardGameDetailPage, parameters: { reactRouter: reactRouterParameters({ routing: { useStoryElement: true, path: '/board-games/:boardGameId', }, location: { pathParams: { boardGameId: 1, }, }, }), }, };
3-3. Chromatic으로 시각 회귀 테스트하기
Storybook은 Chromatic으로 배포하면 무료로 시각 회귀 테스트를 제공해요.
시각 회귀 테스트는 화면이 바뀌었는지를 비교해요.
Pixel을 diff해서 변경된 부분이 있으면 보여줘요.
스냅샷을 찍기 때문에 월 5,000회의 제한이 있어요.
- 각 스토리마다 1개의 스냅샷을 찍기 때문이에요.
스냅샷 상으로 달라진 부분이 있다면 이렇게 표시돼요.
3-4. Chromatic으로 스토리북 배포하기
Storybook은 무료로 배포할 수 있어요
3-4-1. Chromatic에 가입 후 repo를 연결해요.
레포 연결 후 CLI로
chromatic
을 직접 실행해서 최초 배포를 해야 넘어갈 수 있어요.만약 빌드가 영원히 끝나지 않는다면 빌드 오류가 발생한 것일 수 있어요.
build-storybook
을 실행하면 오류를 확인할 수 있으니 확인해보세요Running chromatic storybook build for the first time hangs indefinitely
배포가 되면 아래처럼 대시보드가 생겨요.
3-4-2. GitHub Actions로 PR 푸시 시 자동으로 빌드해요
GitHub Action으로 배포되도록 Action 파일을 하나 생성해야 돼요.
.github/workflows/publish-storybook.yml
파일을 만든 후 아래 내용으로 채우면 돼요.
name: 'Publish Strybook to Chromatic' on: push jobs: publish-storybook-to-chromatic: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - uses: actions/setup-node@v3 with: node-version: 18 cache: 'npm' - run: npm install - uses: chromaui/action@v1 with: projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }} exitZeroOnChanges: true
만약 pnpm을 사용하신다면 아래의 내용으로 사용하시면 돼요
name: 'Publish Storybook to Chromatic' on: push jobs: publish-storybook-to-chromatic: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: pnpm/action-setup@v2 with: version: 8.15.1 - uses: actions/setup-node@v4 with: node-version: 18 cache: 'pnpm' - run: pnpm install - uses: chromaui/action@v1 with: projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }} exitZeroOnChanges: true
이제 레포의 Secret을 생성해줘요.
${{ secrets.CHROMATIC_PROJECT_TOKEN }}
에 들어갈 값이에요.
- Settings > Secrets and variables > New Secret로 설정할 수 있어요.
CI가 정상적으로 설치됐다면 PR을 생성했을 때 아래처럼 배포되는 것을 확인할 수 있을 거에요.