React Vue Angular Web Components More
서로 함께 작동하도록 설계된 컴포넌트들이라면, 두 개 이상의 컴포넌트를 한 번에 렌더링하는 스토리를 작성하는 것이 아주 유용해요. 예를 들어 ButtonGroup, List, Page 컴포넌트 같은 것들이 있겠죠.
문서화하려는 컴포넌트들이 부모-자식 관계일 때는 subcomponents 속성을 사용해서 한꺼번에 문서화할 수 있어요. 특히 자식 컴포넌트가 단독으로 쓰이지 않고 부모 컴포넌트의 일부로만 사용될 때 이 방법이 정말 유용하답니다.
List와 ListItem 컴포넌트를 예로 들어볼게요:
CSF 3 | CSF Next 🧪
List.stories.ts|tsx
import * as React from 'react';
// 여러분이 사용 중인 프레임워크(예: react-vite, nextjs, nextjs-vite 등)로 교체하세요.
import type { Meta, StoryObj } from '@storybook/your-framework';
import { List } from './List';
import { ListItem } from './ListItem';
const meta = {
component: List,
subcomponents: { ListItem }, //👈 ListItem 컴포넌트를 서브컴포넌트로 추가합니다.
} satisfies Meta<typeof List>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = {};
export const OneItem: Story = {
render: (args) => (
<List {...args}>
<ListItem />
</List>
),
};
이렇게 meta(또는 default export)에 subcomponents 속성을 추가하면, ArgTypes와 Controls 테이블에 ListItem의 프롭스(props) 목록을 보여주는 별도의 패널이 생기게 돼요.

하지만 여러분, 서브컴포넌트는 오직 문서화 목적으로만 설계되었다는 점을 명심해야 해요. 그래서 몇 가지 제한 사항이 있답니다:
argTypes는 (해당 기능을 지원하는 렌더러의 경우) 자동으로 추론되며, 수동으로 정의하거나 덮어쓸 수 없어요.이런 제약 사항들을 해결할 수 있는 몇 가지 기술들을 알아볼까요? 특히 상황이 복잡해질 때 아주 유용할 거예요.
스토리 정의를 재사용하면 코드 반복을 줄일 수 있어요. 여기서는 ListItem 스토리의 args를 List 스토리에서 재사용하는 방법을 보여드릴게요.
CSF 3 | CSF Next 🧪
List.stories.ts|tsx
import * as React from 'react';
// 여러분이 사용 중인 프레임워크로 교체하세요.
import type { Meta, StoryObj } from '@storybook/your-framework';
import { List } from './List';
import { ListItem } from './ListItem';
//👇 ListItem에서 필요한 스토리들을 임포트합니다.
import { Selected, Unselected } from './ListItem.stories';
const meta = {
component: List,
} satisfies Meta<typeof List>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ManyItems: Story = {
render: (args) => (
<List {...args}>
<ListItem {...Selected.args} />
<ListItem {...Unselected.args} />
<ListItem {...Unselected.args} />
</List>
),
};
Unchecked 스토리를 그 args와 함께 렌더링함으로써, ListItem 스토리에 있던 입력 데이터를 List에서도 그대로 재사용할 수 있게 되었죠?
하지만 이 방식도 아직 args를 사용해서 ListItem 스토리를 직접 제어하는 건 아니에요. 즉, 컨트롤 패널에서 값을 바꿀 수 없고, 더 복잡한 다른 컴포넌트 스토리에서 재사용하기도 어렵다는 뜻이죠.
상황을 좀 더 개선할 수 있는 한 가지 방법은 렌더링된 서브컴포넌트를 children 인자(arg)로 끌어내는 거예요.
CSF 3 | CSF Next 🧪
List.stories.ts|tsx
// 여러분이 사용 중인 프레임워크로 교체하세요.
import type { Meta, StoryObj } from '@storybook/your-framework';
import { List } from './List';
//👇 ListItem을 임포트하는 대신, 스토리 자체를 임포트합니다.
import { Unchecked } from './ListItem.stories';
const meta = {
component: List,
} satisfies Meta<typeof List>;
export default meta;
type Story = StoryObj<typeof meta>;
export const OneItem: Story = {
args: {
children: <Unchecked {...Unchecked.args} />,
},
};
이제 children이 하나의 arg가 되었으니, 다른 스토리에서도 재사용할 수 있는 가능성이 열렸어요!
다만 이 접근 방식을 쓸 때 주의해야 할 주의사항이 몇 가지 있어요.
children 인자도 다른 모든 인자와 마찬가지로 JSON 직렬화(JSON serializable)가 가능해야 해요. 스토리북에서 에러를 피하려면 다음 수칙을 지켜주세요:
스토리북 팀에서도 현재 children 인자의 전반적인 사용 경험을 개선하기 위해 노력 중이에요. 조만간 컨트롤 패널에서 직접 children을 수정하거나 다른 타입의 컴포넌트들도 자유롭게 사용할 수 있게 될 거예요. 하지만 지금은 이런 제약 사항들을 고려해서 구현해야 합니다!
좀 더 "데이터 중심적인" 또 다른 옵션은 특별한 "스토리 생성용" 템플릿 컴포넌트를 만드는 거예요.
CSF 3 | CSF Next 🧪
List.stories.ts|tsx
// 여러분이 사용 중인 프레임워크로 교체하세요.
import type { Meta, StoryObj } from '@storybook/your-framework';
import { List } from './List';
import { ListItem } from './ListItem';
//👇 ListItem 스토리에서 특정 스토리를 임포트합니다.
import { Unchecked } from './ListItem.stories';
const meta = {
component: List,
} satisfies Meta<typeof List>;
export default meta;
type Story = StoryObj<typeof meta>;
//👇 ListTemplate 구조가 기존 스토리들에 스프레드(spread)될 거예요.
const ListTemplate: Story = {
render: ({ items, ...args }) => {
return (
<List>
{items.map((item) => (
<ListItem {...item} />
))}
</List>
);
},
};
export const Empty = {
...ListTemplate,
args: {
items: [],
},
};
export const OneItem = {
...ListTemplate,
args: {
items: [{ ...Unchecked.args }],
},
};
이 방식은 설정하기가 조금 복잡할 수 있지만, 복합 컴포넌트 안에 있는 각 스토리의 args를 훨씬 쉽게 재사용할 수 있다는 장점이 있어요. 무엇보다 컨트롤(Controls) 패널을 통해 컴포넌트의 인자들을 실시간으로 변경해볼 수 있다는 게 큰 매력이죠!