안녕하세요, 로건입니다 👋
첫 스토리는 누구나 그럴듯해 보이게 짭니다. 문제는 50번째쯤 됐을 때 시작돼요.
argTypes를 빼먹은 스토리가 콘트롤 패널을 비워두고, 어떤 건 action()을 쓰고 어떤 건 fn()을 써서 인터랙션 테스트가 들쭉날쭉해지죠. 보던 스토리에 누군가 play 함수를 새로 추가했는데, 그게 또 showcase grid 위에 달려서 같은 회귀를 N번 잡고 있고요. 저는 그걸 한참 겪은 다음에야 "한 컴포넌트당 대표 스토리에만 play 함수" 같은 한 줄 규칙이 얼마나 중요한지 알았어요.
이 글은 검증 단계에서 200개에 가까운 스토리를 짜며 손에 남은 골격 한 벌과, 거기 따라붙은 잔규칙 열 몇 개에 대한 이야기입니다.
컴포넌트 스토리의 표준 골격
먼저 모든 컴포넌트 스토리가 따라야 하는 골격부터 보여드릴게요. 이건 한 번 정착시키고 나면 다시 손대지 않는 형태입니다.
import type { Meta, StoryObj } from "@storybook/react";
import { fn, userEvent, within, expect } from "@storybook/test";
import { Button } from "@/components/Button";
const meta = {
title: "Atoms/Button",
component: Button,
argTypes: {
tone: { control: "select", options: ["primary", "secondary", "danger"] },
size: { control: "select", options: ["sm", "md", "lg"] },
disabled: { control: "boolean" },
onPress: { action: "pressed" },
},
args: {
tone: "primary",
size: "md",
disabled: false,
children: "Continue",
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { onPress: fn() },
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
expect(args.onPress).toHaveBeenCalledTimes(1);
},
};
export const Disabled: Story = {
args: { disabled: true },
};
export const AllTones: Story = {
args: { tone: "primary" }, // ignored — story renders its own grid
render: () => (
<>
<Button tone="primary">Primary</Button>
<Button tone="secondary">Secondary</Button>
<Button tone="danger">Danger</Button>
</>
),
};여기 들어있는 결정들을 하나씩 풀어볼게요.
규칙 1 — argTypes는 전부 노출한다
스토리북의 콘트롤 패널이 비어 있다면, 그 컴포넌트는 사실상 검수가 불가능합니다. 디자이너가 "tone을 danger로 바꿔봐도 잘 보이나요?"라고 물었을 때, 콘트롤이 없으면 코드를 새로 쓰러 가야 하거든요.
모든 props를 argTypes에 노출하세요. 콘트롤은 검수의 도구이지, 개발자의 장난감이 아닙니다.
규칙 2 — 대표 스토리 1개에만 play 함수를
이게 가장 늦게 깨달은 규칙이에요. AllSizes, AllTones 같은 showcase grid 스토리에 play 함수를 달면 어떻게 되는지 아세요? 같은 회귀를 N번 잡는 거미줄이 됩니다.
| 패턴 | 회귀 1건당 빨간색 표시 | 의미 |
|---|---|---|
| Default에만 play | 1개 | 한 번 잡힘 → 한 곳 수정 |
| Default + Disabled + AllTones에 play | 3개 이상 | 같은 걸 여러 곳에서 잡음 → 마찰 |
안전망의 목적은 다른 회귀를 한 번씩 잡는 것이지, 같은 것을 여러 번 잡는 게 아니에요. 한 곳에서 잡힐 회귀를 열 곳에서 잡으려는 노력은 안전망이 아니라 마찰입니다.
규칙 3 — fn()과 action()을 구분한다
이게 처음엔 헷갈렸어요. 두 함수 다 핸들러 자리에 끼우는 더미인 건 같은데, 역할이 달라요.
// action() — 로깅만. Actions 탭에 나타남.
argTypes: {
onPress: {
action: "pressed";
}
}
// fn() — spy + 로깅. play 함수에서 .toHaveBeenCalled() 검증 가능.
args: {
onPress: fn();
}기준은 단순합니다. play로 검증할 거면 fn(), 콘트롤 패널에서 핸들러가 호출되는 걸 시각적으로만 보고 싶으면 action(). 둘이 섞이면 어떤 스토리는 검증이 되고 어떤 건 안 되는 상태가 만들어져요. 컨벤션을 한 번 박아두는 게 좋습니다.
규칙 4 — production 컴포넌트는 절대 수정하지 않는다
Storybook을 짜다 보면 production 컴포넌트의 어떤 props가 좀 더 있었으면 좋겠다 싶을 때가 있어요. "여기 testID만 추가하면 selector가 깔끔할 텐데..." 같은 생각이요. 그 유혹을 참아야 합니다.
production 컴포넌트 수정은 production 코드 리뷰로. Storybook 작업은 Storybook 디렉토리 안에서만.
이 규칙이 없으면 Storybook 도입이 production 코드의 잔변경을 끌고 들어와 PR review가 끝없이 늘어집니다. Storybook은 production 위에 얹는 관찰자여야 해요.
규칙 5 — RN 컴포넌트의 스타일은 StyleSheet.create로
styled-components가 익숙한 분들이 처음 RN으로 넘어오면 흔히 하는 실수예요. styled API가 깔끔해 보여서 가져다 쓰는 건데, RN-Web Storybook에서 두 가지 문제가 생깁니다.
- SSR diff에서 className 순서가 어긋나 hydration warning
- Chromatic diff에서 같은 화면인데 매번 hash가 다른 className으로 잡혀 false positive
해결은 단순해요. 모든 스타일을 StyleSheet.create로 통일합니다.
const styles = StyleSheet.create({
container: { paddingHorizontal: 16, paddingVertical: 12 },
label: { fontSize: 14, fontWeight: "600", color: tokens.color.text },
});이 한 줄 정책이 Storybook 도입 후 3개월쯤 됐을 때 false positive 수십 건을 한꺼번에 정리하는 비결이었습니다.
규칙 6 — Portal modal은 canvasElement.parentElement로
Modal, BottomSheet, Tooltip 같은 게 React Portal로 렌더되면 within(canvasElement)로 찾을 수가 없어요. 컨테이너 밖에 그려지니까요.
play: async ({ canvasElement }) => {
const root = canvasElement.parentElement!; // ← portal 포함
const dialog = within(root).getByRole("dialog");
await userEvent.click(within(dialog).getByText("확인"));
},처음 만났을 때 1시간을 헤맸어요. 지금은 modal/sheet 계열 컴포넌트에 일관되게 적용합니다.
규칙 7 — 페이지 스토리에는 모듈 스코프 fn() + mockClear()
이건 살짝 고급 패턴인데, 페이지 스토리에서 여러 핸들러를 한 번에 검증해야 할 때 쓰는 패턴이에요.
const onContinue = fn();
const onCancel = fn();
export const Submit: Story = {
args: { onContinue, onCancel },
play: async ({ canvasElement }) => {
onContinue.mockClear();
onCancel.mockClear();
const c = within(canvasElement);
await userEvent.click(c.getByText("계속"));
expect(onContinue).toHaveBeenCalled();
expect(onCancel).not.toHaveBeenCalled();
},
};스토리 사이에 spy 상태가 누적되면 결과가 비결정적이 돼요. 모듈 스코프에 선언하고 play 진입 시 mockClear() 하는 한 줄 패턴이 답입니다.
그래서 200번째 스토리는 어떻게 짜냐면
이 규칙들이 다 박히고 나면, 새 컴포넌트 스토리 한 개를 짜는 데 10분 안쪽이에요. 30초 정도 골격 복붙, argTypes 채우고, Default에 play 하나 달고, showcase grid는 인스트루먼트 없이 — 끝.
좋은 규칙은 처음엔 답답하지만, 50번째쯤 되면 손이 알아서 따라갑니다.
다음 편은 정반대 이야기예요. 야심차게 짜다가 한 번에 다 지운 적이 있어요. 페이지 스토리 106개를. 그 결정에 대해서, 그리고 안 짜는 결정이 짜는 결정보다 어려웠던 그 오후에 대해 풀어볼게요.