
1편에서는 Storybook이 뭔지, 왜 필요한지를 정리했다. 이번 글에서는 Storybook을 설치하고, 공식 문서의 Button 예제를 따라치며 Story가 어떻게 작성되는지 알아본다.
Storybook은 기존 프로젝트의 루트 디렉토리에서 CLI 명령어 하나로 설치할 수 있다. 우리 프로젝트는 pnpm을 사용하므로
pnpm dlx storybook@latest init
설치 과정에서 Storybook이 프로젝트의 의존성을 분석해서 최적의 설정을 자동으로 잡아준다. Next.js 프로젝트에서 실행하면 @storybook/nextjs 프레임워크가 자동으로 선택된다.
설치 중에 두 가지를 물어본다.
"New to Storybook?" — 처음이라면 Yes를 선택하면 된다. 인터랙티브 투어와 예제 Story가 생성되는데, 예제를 살펴보는 것만으로도 구조를 파악하는 데 도움이 된다.
"What configuration should we install?" — Recommended를 선택하면 컴포넌트 개발, 문서화, 테스팅, 접근성 기능이 모두 포함된다. 학습 목적이라면 Recommended가 좋다.
설치가 끝나면 프로젝트에 .storybook 폴더와 예제 파일들이 생긴다.
프로젝트 루트/
├─ .storybook/
│ ├─ main.ts
│ └─ preview.ts
├─ src/
│ └─ stories/
│ ├─ Button.tsx
│ ├─ Button.stories.ts
│ ├─ Header.tsx
│ ├─ Header.stories.ts
│ ├─ Page.tsx
│ └─ Page.stories.ts
└─ ...
.storybook/main.ts는 Storybook 전체 설정을 관리하는 파일이다. 어떤 프레임워크를 사용하는지, Story 파일을 어디서 찾을지, 어떤 애드온을 쓸지 등을 여기서 정의한다.
.storybook/preview.ts는 모든 Story에 공통으로 적용되는 설정을 관리하는 파일이다. 글로벌 데코레이터, 파라미터 등을 여기서 정의한다.
pnpm storybook
기본적으로 localhost:6006에서 실행된다. 왼쪽 사이드바에 예제 컴포넌트들(Button, Header, Page)이 보이고, 각각을 클릭하면 해당 컴포넌트가 렌더링된 모습을 확인할 수 있다.
공식 문서의 What's a story? 페이지에 이런 설명이 나온다.
A story captures the rendered state of a UI component. Developers write multiple stories per component that describe all the 'interesting' states a component can support.
Story는 UI 컴포넌트의 렌더링된 상태를 캡처한 것이다. 하나의 컴포넌트에 대해 여러 개의 Story를 작성해서, 그 컴포넌트가 가질 수 있는 다양한 상태를 기록한다.
Button 컴포넌트를 예로 들면
"이 컴포넌트에 이런 props를 주면 이렇게 보인다"를 하나하나 정의해두는 것이다.
Story를 작성하는 표준 포맷이 있다. CSF(Component Story Format)라고 부르며, ES6 모듈 기반의 작성 방식이다. 핵심 구성 요소는 두 가지다.
Story 파일은 컴포넌트 파일 옆에 컴포넌트명.stories.ts 형식으로 둔다.
components/
├─ Button/
│ ├─ Button.tsx
│ └─ Button.stories.ts
공식 문서의 Button 예제를 따라 작성해보자.
import type { Meta, StoryObj } from '@storybook/nextjs';
import { Button } from './Button';
const meta = {
component: Button,
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
primary: true,
label: 'Button',
},
};
코드를 하나씩 뜯어보자.
const meta = {
component: Button,
} satisfies Meta<typeof Button>;
export default meta;
이 Story 파일이 어떤 컴포넌트에 대한 것인지를 Storybook에 알려준다. satisfies Meta<typeof Button>은 TypeScript의 satisfies 연산자로 타입 안전성을 확보하는 부분이다. 잘못된 설정을 넣으면 컴파일 타임에 에러를 잡아준다.
type Story = StoryObj<typeof meta>;
이후 작성할 개별 Story들의 타입이다. meta에서 지정한 컴포넌트의 props 정보를 기반으로 타입이 추론되므로, args에 잘못된 값을 넣으면 타입 에러가 난다.
export const Primary: Story = {
args: {
primary: true,
label: 'Button',
},
};
Primary라는 이름의 Story를 내보낸다. args는 컴포넌트에 전달할 props다. 이 Story는 "Button 컴포넌트에 primary: true, label: 'Button'을 전달하면 이렇게 보인다"를 정의하는 것이다.
하나의 컴포넌트에 여러 Story를 작성하면, 다양한 상태를 한눈에 관리할 수 있다.
import type { Meta, StoryObj } from '@storybook/nextjs';
import { Button } from './Button';
const meta = {
component: Button,
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
backgroundColor: '#ff0',
label: 'Button',
},
};
export const Secondary: Story = {
args: {
...Primary.args,
label: '😄👍😍💯',
},
};
export const Tertiary: Story = {
args: {
...Primary.args,
label: '📚📕📈🤓',
},
};
눈여겨볼 부분은 ...Primary.args다. Primary Story의 args를 스프레드해서 재사용하고, 바꾸고 싶은 부분만 오버라이드하는 패턴이다. Story가 많아질수록 이렇게 args를 재사용하면 중복을 줄일 수 있다.
컴포넌트를 조합해서 쓰는 경우, 다른 컴포넌트의 Story에서 args를 가져와 쓸 수 있다.
import type { Meta, StoryObj } from '@storybook/nextjs';
import { ButtonGroup } from '../ButtonGroup';
import * as ButtonStories from './Button.stories';
const meta = {
component: ButtonGroup,
} satisfies Meta<typeof ButtonGroup>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Pair: Story = {
args: {
buttons: [
{ ...ButtonStories.Primary.args },
{ ...ButtonStories.Secondary.args },
],
orientation: 'horizontal',
},
};
Button의 props가 변경되어도 Button의 Story만 수정하면 ButtonGroup의 Story도 자동으로 반영된다. 1편에서 다뤘던 Component-Driven Development의 바텀업 방식이 코드 레벨에서 이렇게 동작하는 셈이다.
args는 Storybook에서 컴포넌트에 전달되는 인자를 통칭하는 용어다. React 기준으로 props에 해당한다.
Story에서 args를 정의하면 두 가지가 동시에 일어난다.
Controls 패널에 해당 args가 표시되어, 실시간으로 값을 변경할 수 있다.Controls 패널이 유용한 이유는, 코드를 수정하지 않고도 다양한 상태를 탐색할 수 있기 때문이다. "이 버튼에 아주 긴 텍스트를 넣으면 어떻게 되지?"를 확인하려면, 코드를 고치고 저장하는 대신 Controls 패널에서 label 값만 바꿔보면 된다.
기본적으로 Story는 meta에 정의된 컴포넌트를 args와 함께 렌더링한다. 그런데 컴포넌트를 다른 컴포넌트 안에 넣어서 렌더링하고 싶을 때가 있다. 이럴 때 render 함수를 사용한다.
import type { Meta, StoryObj } from '@storybook/nextjs';
import { Alert } from './Alert';
import { Button } from './Button';
const meta = {
component: Button,
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const PrimaryInAlert: Story = {
args: {
primary: true,
label: 'Button',
},
render: (args) => (
<Alert>
Alert text
<Button {...args} />
</Alert>
),
};
render 함수에서 args를 스프레드({...args})하는 게 중요하다. 이렇게 해야 Controls 패널에서 값을 변경했을 때 렌더링에 반영된다.
여러 Story에서 같은 render 함수를 쓰고 싶다면, meta 레벨에 정의할 수도 있다.
const meta = {
component: Button,
render: (args) => (
<Alert>
Alert text
<Button {...args} />
</Alert>
),
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const DefaultInAlert: Story = {
args: { label: 'Button' },
};
export const PrimaryInAlert: Story = {
args: { primary: true, label: 'Button' },
};
meta에 정의한 render를 개별 Story에서 오버라이드하는 것도 가능하다. args와 마찬가지로 하위 레벨이 상위 레벨을 덮어쓰는 구조다.
pnpm dlx storybook@latest init으로 설치하면 프로젝트를 자동 분석해서 최적의 설정을 잡아준다.args에 props를 정의하면 렌더링과 Controls 패널이 동시에 동작한다....Primary.args 패턴으로 Story 간 데이터를 재사용할 수 있고, 다른 컴포넌트의 Story를 import해서 조합하는 것도 가능하다.