Storybook 타입을 포기하고 있었다는 걸 깨달은 날
회사에서 Vue 3 모노레포를 운영하고 있는데, 어느 날 컴포넌트 prop 이름을 바꿨더니 Storybook 미리보기가 조용히 깨져 있었습니다. distributionSwitch를 distributionEnabled로 리네이밍했는데, story args에는 옛날 이름이 그대로. 에러도 안 나고, 빌드도 통과하고, 누가 Storybook 열어보기 전까지는 아무도 모르는 상태.
솔직히 Storybook에 신경을 많이 못 쓰고 있었습니다. 제품 코드 작성하기도 바쁜데 stories까지 꼼꼼히 챙기기가 쉽지 않거든요. "미리보기 잘 뜨면 된 거 아닌가" 하고 넘겼던 게 사실이고, 타입이 좀 안 맞아도 동작만 하면 됐었습니다.
근데 깨진 stories가 쌓이니까 결국 Storybook 자체를 안 보게 되더라고요. 그럼 왜 운영하나 싶어서 한번 제대로 봤습니다.
any가 퍼진 경위
stories 파일을 쭉 훑어보니까 StoryObj<any>가 12개, render: (args: any)가 8개, as ComponentProps 단언이 15개. 사실상 타입 체크가 없는 상태.
원인은 discriminated union props였습니다. 메시지 미리보기 같은 컴포넌트가 "편집 모드"와 "조회 모드"를 union으로 받는데:
type Props = EditModeProps | ViewModeProps;
Storybook의 타입 시스템이 이걸 처리할 때 양쪽 필드를 동시에 요구하는 식으로 변환해버립니다. StoryObj<typeof meta>로는 args 타입이 안 맞아요. 그래서 누군가가 StoryObj<any>로 바꿨고, 다른 파일에서도 "여기도 any 하면 되네" 하고 퍼진 거죠.
Vue 환경이 특히 이런 문제에 취약한 게, React는 ComponentProps<typeof Component>가 비교적 깔끔하게 추론되는 반면 Vue는 DefineComponent의 제네릭 구조가 복잡해서 Storybook 쪽 타입 유틸과 잘 안 맞는 경우가 많았습니다. ComponentPropsAndSlots 같은 타입이 Vue의 props inference와 부딪히면 그냥 포기하게 되거든요.
tsconfig 분리로는 부족했다
모노레포에서 stories 타입 체크를 하려면 보통 tsconfig.stories.json을 따로 만들어서 include에 *.stories.ts를 넣고 별도로 돌리는 방법을 쓰는데, 우리도 그렇게 하고 있었습니다.
문제는 이게 "에러가 존재한다"는 걸 알려주긴 하는데 개발 중에는 안 돌리게 된다는 거예요. CI에서도 제품 빌드 파이프라인이랑 분리되어 있으니까 stories 타입 체크가 실패해도 머지가 되고요. 결국 별도 tsconfig은 "나중에 한번 정리해야지" 하는 기술 부채 확인 도구 정도가 됐습니다.
진짜 필요한 건 에디터에서 story 파일 열었을 때 바로 빨간 줄이 뜨는 것, 즉 stories가 제품 코드랑 같은 타입 체크 맥락에서 돌아가는 거였습니다.
공식 문서에 답이 있었다
Storybook 문서에 "Typing custom args"라는 섹션이 있는데, 솔직히 처음엔 넘겼었습니다. 이번에 제대로 읽어보니까 정확히 우리 케이스.
union을 flat intersection으로 풀어서 custom args type을 만드는 겁니다:
type PreviewStoryProps = BaseProps & {
mode: PreviewMode;
macroValues?: Record<string, string>;
deviceType?: "android" | "ios";
};
const meta = {
title: "Components/MessagePreview",
component: MessagePreview,
} satisfies Meta<PreviewStoryProps>;
type Story = StoryObj<PreviewStoryProps>;
Meta<typeof Component> 대신 Meta<CustomArgsType>을 쓰고, satisfies로 meta 객체의 타입 체크를 유지하는 것. 이러면 render의 args도 자동 추론됩니다:
render: (args) => ({ // args: PreviewStoryProps
components: { MessagePreview },
setup() { return { args }; },
template: `<MessagePreview v-bind="args" />`,
}),
any 없이.
모노레포 쪽 삽질
타입을 고치려고 하니까 에디터에서 Meta, StoryObj import 자체가 빨간 줄이었습니다. .storybook/ 디렉토리가 pnpm workspace 밖에 있어서 별도 lock 파일이 생기고, 타입이 resolve가 안 되는 상태였어요.
# pnpm-workspace.yaml
packages:
- '.storybook' # ← 이것만 추가
workspace 패키지로 편입시키고 root에 @storybook/vue3 devDependency 추가하니까 해결. Storybook CLI가 처음에 만들어주는 구조를 그대로 두면 workspace 밖에 놓이는데, 이걸 몇 달 동안 방치했던 거고, 그 동안 타입이 안 잡히니까 any로 도피한 악순환이었습니다.
그래서 지금은
컴포넌트 props를 바꾸면 stories에서 에디터가 바로 잡아줍니다. 별도 tsconfig 돌릴 필요 없이 일반 개발 흐름에서요. 이게 되니까 stories를 다시 보게 됐고, 새 컴포넌트 만들 때도 story부터 쓰게 되더라고요.
다만 아직 해결 못 한 것도 있습니다. Vue의 slot props라든가, v-model 관련 args는 Storybook 타입 시스템이 제대로 지원을 안 해서 여전히 어색한 부분이 있어요. 이건 Storybook Vue3 어댑터 쪽이 더 발전해야 할 영역인 것 같고요.
결국 "Storybook 타입은 원래 좀 불안정하니까" 하고 체념했던 게 문제였습니다. 공식 패턴이 있는데 안 찾아본 것도 있고, 모노레포 세팅이 타입 resolve를 막고 있었던 것도 있고. 한번 잡고 나니까 왜 진작 안 했나 싶긴 합니다.