F-6: Storybook — 컴포넌트를 카탈로그로 만듭니다
목차 45
안녕하세요, 홍순구 튜터입니다.
오늘 수업은 이렇게 시작하는데, 설치 명령 하나를 치면 초록색 체크가 뜨고 마지막 줄에 이렇게 적혀 있어요.
Storybook setup completed
종료 코드도 0이라 잘 됐다 싶은데, 조금 위를 올려다보면 이런 줄이 섞여 있습니다.
■ Installation of dependencies failed!
...
✅ @chromatic-com/storybook
❌ @storybook/addon-vitest
❌ @storybook/addon-a11y
✅ @storybook/addon-docs
넷 중 둘이 실패했는데, 하필 그 둘이 오늘 우리가 쓰려는 것들이에요. 하나는 "이야기 하나가 곧 테스트가 된다"를 만들어주는 것이고, 다른 하나는 접근성을 자동으로 검사해주는 것입니다.
더 고약한 건 설정 파일입니다. 열어보면 네 개가 전부 얌전히 적혀 있어요. 설치되지도 않은 것까지요. 파일만 보면 성공한 것처럼 보입니다.
지난 시간에 우리는 초록불을 의심하는 법을 배웠습니다. 통과한 판도 왜 통과했는지 물어야 한다고요. 오늘은 그 의심을 설치 도구에까지 밀어붙이는 데서 시작합니다.
지난 시간 마지막에는 이런 약속도 했어요. 버튼 하나가 큰 것, 작은 것, 눌린 것, 잠긴 것으로 어떻게 보이는지 한 화면에 늘어놓아 보고, 그 늘어놓은 것 자체가 판이 되게 하자고요. 오늘 둘 다 합니다.
오늘의 여정
Step 1 설치가 "완료" 라고 말합니다 (그런데 절반이 안 됐어요)
Step 2 첫 이야기를 세웁니다 (세우고 보니 민낯입니다)
Step 3 혼자 못 서는 컴포넌트 (타입이 먼저 막습니다)
Step 4 한 화면에 여러 상태 (지난 시간의 약속을 갚습니다)
Step 5 이야기가 곧 판이 됩니다 ← 오늘의 핵심
Step 6 눌러보는 이야기
Step 7 접근성은 켜야 잡습니다 (켜자마자 5건 나옵니다)
Step 8 지난 시간의 목록을 다시 씁니다
💡 오늘 수업의 핵심 — "이야기 하나를 써두면 문서와 개발 화면과 판을 한꺼번에 얻습니다"
지금까지 우리가 만든 판은 전부 "이렇게 하면 이렇게 돼야 한다"를 글로 적은 것이었습니다. 오늘 만드는 것은 조금 다른데, 컴포넌트가 어떤 상태일 때 어떻게 보이는지를 한 장면씩 늘어놓으면 그것이 세 가지 일을 동시에 합니다. 팀원에게는 문서가 되고, 나에게는 앱을 안 켜고 컴포넌트만 보는 개발 화면이 되고, 그대로 판이 됩니다.
🎯 학습 목표
- 컴포넌트를 앱에서 떼어내 카탈로그에 세우고, 상태별로 여러 장면을 만들 수 있다.
- 그 장면들이 그대로 브라우저에서 도는 판이 되게 배선하고, 눌러보는 검사까지 붙일 수 있다.
- 접근성 검사를 실제로 실패하는 검사로 켜고, 나온 위반을 고칠지 미룰지 근거를 갖고 정할 수 있다.
Step 1: "완료 라고 말했는데 절반이 안 됐습니다"
카탈로그를 만들어주는 도구를 들입니다. 이름은 Storybook이고, 우리가 쓸 버전은 10입니다.
# apps/web-spa 에서
npx storybook@latest init
이 명령 하나가 설정 파일을 만들고, 필요한 꾸러미를 설치하고, 예제 이야기까지 만들어준 뒤, 끝에 "완료"라고 말합니다.
그런데 종료 코드가 거짓말을 합니다
앞에서 본 그 화면이 나오는데, Installation of dependencies failed!가 중간에 있고, 애드온 넷 중 둘이 ❌ 인데, 마지막 줄은 "완료"이고 종료 코드는 0입니다.
지난 시간에 우리는 종료 코드로 판정하는 법을 배웠죠. 빌드가 성공했는지는 화면이 아니라 종료 코드로 본다고 했는데, 여기서는 그 종료 코드마저 0입니다. 도구가 스스로 "치명적이지 않은 오류"라고 판단한 거예요.
만들어진 설정 파일을 열어봅시다.
// apps/web-spa/.storybook/main.ts
const config: StorybookConfig = {
"stories": [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [
getAbsolutePath('@chromatic-com/storybook'),
getAbsolutePath('@storybook/addon-vitest'),
getAbsolutePath('@storybook/addon-a11y'),
getAbsolutePath('@storybook/addon-docs')
],
"framework": getAbsolutePath('@storybook/react-vite')
};
넷이 전부 적혀 있습니다. 설치가 안 된 둘까지요. 이 파일만 보고 "설정 끝났네" 하고 넘어가면, 나중에 접근성 검사가 아무것도 안 잡을 때 이유를 찾느라 한참을 헤매게 됩니다.
왜 실패했는지 갈라봅시다
오류 메시지는 이렇습니다.
npm error Cannot read properties of null (reading 'edgesOut')
이건 npm이 스스로 터진 것이지 우리에게 무엇이 문제인지 알려주는 메시지가 아니니까, 하나씩 빼보면서 범인을 찾아야 합니다. 저는 이렇게 갈랐어요.
| 의심한 것 | 결과 |
|---|---|
| 여러 앱을 묶어 쓰는 구조 때문인가 | 바깥에서 설치해도 똑같이 실패 |
도구가 적어둔 "latest"라는 글자 때문인가 |
실제 버전으로 바꿔도 똑같이 실패 |
@storybook/addon-vitest가 문제인가 |
빼도 똑같이 실패 |
| 브라우저에서 판을 돌리는 꾸러미 둘이 문제인가 | 둘 다 빼니 설치 성공 |
그중 @vitest/browser 하나만 되돌리면 |
실패 재현 — 범인 확정 |
범인은 @vitest/browser 였습니다. 이 꾸러미는 자기가 함께 쓸 Vitest 버전을 정확한 한 개로 못 박습니다.
@vitest/browser@4.1.11 → vitest "4.1.11"
@vitest/browser@4.1.10 → vitest "4.1.10"
보통 꾸러미는 ^4.1.10처럼 "4.1.10 이상이면 된다"로 적는데 이건 아니에요. 도구가 적어준 것도 ^4.1.10이었지만 그건 4.1.11로 읽히고, 우리 Vitest는 4.1.10이거든요. 어긋나니까 npm이 맞는 조합을 찾아 버전을 거슬러 내려가다가 3점대에 닿고, 그 시절 꾸러미를 풀다가 스스로 터진 겁니다.
해법은 한 줄이라서, 우리가 쓰는 Vitest와 정확히 같은 번호로 못 박습니다.
// apps/web-spa/package.json
"@vitest/browser": "4.1.10",
"@vitest/browser-playwright": "4.1.10",
이렇게 하면 설치가 통과합니다.
💡 한 줄 정리
설치 도구가 "완료"라고 말하고 종료 코드가 0 이어도 절반만 됐을 수 있습니다. 무엇이 실패했는지는 도구가 알려주는 마지막 줄이 아니라 중간 줄에 있고, 원인은 하나씩 빼보면서 갈라야 나옵니다.
🙋 학생 질문 — "튜터님, 이런 건 그냥 검색해서 나온 대로 하면 안 되나요?"
이 오류를 검색하면 대부분 "node_modules 지우고 다시 설치하세요"가 나옵니다. 그렇게 해도 안 됩니다. 우리 경우의 원인은 캐시가 아니라 버전 조합이었으니까요.
이 조합은 여러분 저장소마다 달라서 여러분의 Vitest가 4.1.11이면 @vitest/browser도 4.1.11이어야 하고, 외울 것은 번호가 아니라 "이 꾸러미는 정확한 버전을 요구한다"는 사실입니다.
확인하는 방법은 간단해요.
npm view @vitest/browser peerDependencies
캐럿(^) 없이 번호만 적혀 있으면 정확히 맞춰야 한다는 뜻입니다.
Step 2: "첫 이야기 — 세우고 보니 민낯입니다"
첫 이야기를 씁니다. 우리 앱이 실제로 쓰는 아바타부터요.
// apps/web-spa/src/components/ui/avatar.stories.tsx
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Avatar, AvatarFallback, AvatarImage } from './avatar';
const meta = {
title: '카탈로그/Avatar',
component: Avatar,
} satisfies Meta<typeof Avatar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { size: 'default' },
render: (args) => (
<Avatar {...args}>
<AvatarImage src="https://picsum.photos/seed/jaehoon/64/64" alt="jaehoon 프로필 사진" />
<AvatarFallback>재훈</AvatarFallback>
</Avatar>
),
};
읽는 법은 이렇습니다. meta는 이 파일이 무엇을 카탈로그에 세우는지 한 번만 적어두는 곳이에요. title이 왼쪽 목록의 경로가 되고, component가 주인공입니다. 그 아래 export 하는 것 하나하나가 장면 하나고요.
크기를 세 가지로 늘어놓고, 사진이 깨진 경우도 만들어둡니다.
export const Small: Story = {
...Default,
args: { size: 'sm' },
};
export const Large: Story = {
...Default,
args: { size: 'lg' },
};
export const NoImage: Story = {
args: { size: 'default' },
render: (args) => (
<Avatar {...args}>
<AvatarImage src="https://example.com/없는-사진.png" alt="minji 프로필 사진" />
<AvatarFallback>민지</AvatarFallback>
</Avatar>
),
};
띄워봅시다.
npm run storybook
그런데 민낯입니다
떴는데 뭔가 이상한데, 동그랗지도 않고 크기도 안 맞습니다. 계산된 값을 재보면 이렇습니다.
globals.css를 안 이었을 때 |
이었을 때 | |
|---|---|---|
display |
inline |
flex |
| 가로 / 세로 | auto / auto |
32px / 32px |
| 모서리 | 0px |
완전히 둥글게 |
| 사진 계산 폭 | 64px | 32px |
| Tailwind 규칙 | 없음 | 있음 |
사진이 64px 로 나오고 있었어요. 우리가 준 크기가 아니라 원본 그대로입니다. 아무 규칙도 안 걸려 있으니 브라우저가 사진을 자기 크기대로 그린 거죠.
이유는 이렇습니다. 카탈로그는 우리 앱의 index.html을 쓰지 않아요. 자기 화면을 따로 그립니다. 그러니 우리가 앱에 걸어둔 스타일도 여기서 다시 한 번 이어줘야 합니다.
// apps/web-spa/.storybook/preview.tsx
import '../src/styles/globals.css';
한 줄입니다. 넣고 새로고침하면 제대로 나옵니다. 이 한 줄이 화면에 더해주는 CSS가 53,729자예요.
크기 세 가지가 이제 소스에 적힌 그대로 나옵니다.
| 장면 | 크기 |
|---|---|
| Small | 24px |
| Default | 32px |
| Large | 40px |
NoImage를 열어보면 재미있는 걸 볼 수 있는데, <img> 태그가 화면에 아예 없습니다. 사진을 못 받으면 그 자리에 글자만 남기고 태그 자체를 안 그리는 거예요.
이게 카탈로그의 첫 번째 값입니다. 앱에서 이 상태를 보려면 네트워크를 끊어야 하는데, 여기서는 주소를 하나 잘못 적어두는 것으로 끝납니다.
💡 한 줄 정리
카탈로그는 앱과 다른 화면에서 그려집니다. 그려진다는 것과 제대로 보인다는 것은 다른 얘기라, 앱에 걸어둔 스타일을 카탈로그에도 한 번 더 이어줘야 합니다.
🙋 학생 질문 — "스타일이 안 걸린 걸 왜 바로 못 알아챘을까요?"
아바타가 작고 동그란 사진이라 그렇습니다. 규칙이 안 걸려도 사진은 뜨거든요. 그냥 조금 크고 네모날 뿐이에요. "원래 이렇게 생겼나?" 하고 넘어가기 딱 좋습니다.
그리고 이게 이 회차 내내 반복될 이야기예요. 아무 일도 안 일어나는 것과 잘못된 일이 일어나는 것은 구별하기 어렵습니다. Step 1의 설치도 그랬고, Step 7의 접근성 검사도 그럴 겁니다.
그래서 눈으로 보는 대신 계산된 값을 재보는 습관이 필요해요. 개발자 도구에서 요소를 고르고 Computed 탭을 보면 위 표의 숫자를 그대로 확인할 수 있습니다.
Step 3: "혼자 못 서는 컴포넌트"
다음은 댓글 입력칸인데, 이건 아바타처럼 간단하지 않아요.
// apps/web-spa/src/components/CommentInput.tsx
interface CommentInputProps {
value: string;
onChange: React.ChangeEventHandler<HTMLInputElement>;
ref: React.Ref<HTMLInputElement>;
}
셋 다 필수인데, 이 컴포넌트는 자기 값을 스스로 안 들고 있어요. 밖에서 value를 주고 onChange로 되돌려받아야 글자가 써집니다. B-5에서 배운 그 방식이죠.
앱에서는 카드가 그 역할을 해줬는데, 카탈로그에는 그 "밖"이 없어요.
타입이 먼저 막습니다
일단 아바타처럼 써봅시다.
export const Empty: Story = {
render: () => <StatefulCommentInput initial="" />,
};
타입 검사가 이렇게 막습니다.
TS2322: Type '{ render: () => React.JSX.Element; }' is not assignable to type
'StoryAnnotations$1<ReactRenderer, { value: string; onChange: ...; ref: ...; }, ...>'.
Property 'args' is missing in type '{ render: () => React.JSX.Element; }'
but required in type '{ args: { value: string; onChange: ...; ref: ...; } }'.
"args가 없다" 고 하죠. 우리는 render로 직접 그릴 거니까 args가 필요 없다고 생각했는데, 타입은 그렇게 안 봅니다. 필수 값을 가진 컴포넌트를 카탈로그에 세우려면 그 약속을 채워야 한다는 거예요.
이건 막아주는 게 맞습니다. 카탈로그는 "이 컴포넌트는 이런 값을 받는다"를 보여주는 곳이기도 하니까요. 받는 값을 안 적고 세우면 그 문서가 거짓말을 하게 됩니다.
값을 쥐는 일은 카탈로그가 맡습니다
// apps/web-spa/src/components/CommentInput.stories.tsx
const meta = {
title: '카탈로그/CommentInput',
component: CommentInput,
args: {
value: '',
onChange: () => {},
ref: null,
},
} satisfies Meta<typeof CommentInput>;
function StatefulCommentInput({ initial }: { initial: string }) {
const [value, setValue] = useState(initial);
const ref = useRef<HTMLInputElement>(null);
return (
<CommentInput value={value} onChange={(event) => setValue(event.target.value)} ref={ref} />
);
}
export const Empty: Story = {
render: (args) => <StatefulCommentInput initial={args.value} />,
};
export const Typed: Story = {
args: { value: '사진 좋네요' },
render: (args) => <StatefulCommentInput initial={args.value} />,
};
meta.args로 약속을 채우고, 실제로 글자를 쥐는 일은 StatefulCommentInput이 맡는데, args.value가 첫 글자를 정하니까 두 장면이 같은 render를 나눠 쓸 수 있어요.
띄워놓고 직접 쳐보면 글자가 잘 들어갑니다.
💡 한 줄 정리
값을 스스로 안 들고 있는 컴포넌트는 카탈로그에 혼자 못 섭니다. 앱에서 부모가 해주던 일을 카탈로그가 대신 맡아야 하고, 필수 값을 안 적으면 타입 검사가 먼저 막아줍니다.
🙋 학생 질문 — "onChange와 ref는 아무 일도 안 하는데 왜 적나요?"
맞습니다. meta.args의 onChange: () => {}와 ref: null은 실제로 아무 일도 안 해요. 진짜 일은 StatefulCommentInput 안의 것들이 합니다.
왜 적냐면, 저 셋이 이 컴포넌트가 받기로 한 값이기 때문입니다. 카탈로그의 오른쪽 칸에는 컴포넌트가 받는 값 목록이 뜨는데, 안 적으면 거기가 비어 보여요. 팀원이 보고 "아 이건 그냥 놓으면 되는구나" 하고 오해합니다.
다르게 말하면 이건 귀찮은 절차가 아니라 문서를 채우는 일이라서, 타입이 막는 게 고마운 거고요.
Step 4: "한 화면에 여러 상태를 늘어놓습니다"
지난 시간에 한 약속을 갚을 차례입니다. 버튼 하나가 큰 것, 작은 것, 눌린 것, 잠긴 것으로 어떻게 보이는지 늘어놓기로 했죠.
// apps/web-spa/src/components/ui/button.stories.tsx
const meta = {
title: '카탈로그/Button',
component: Button,
argTypes: {
variant: {
control: 'select',
options: ['default', 'outline', 'secondary', 'ghost', 'destructive', 'link'],
},
size: {
control: 'select',
options: ['default', 'xs', 'sm', 'lg', 'icon', 'icon-xs', 'icon-sm', 'icon-lg'],
},
disabled: { control: 'boolean' },
},
args: {
children: '팔로우',
variant: 'default',
size: 'default',
},
} satisfies Meta<typeof Button>;
argTypes가 새로 나왔습니다. 어떤 값이 있는지 적어두면 카탈로그 오른쪽에 고르는 칸이 생겨요. 목록을 바꿔가며 눌러보면 화면이 즉시 바뀝니다.
약속한 네 가지는 이렇습니다.
export const Large: Story = {
args: { size: 'lg' },
};
export const Small: Story = {
args: { size: 'sm' },
};
export const Pressed: Story = {
args: { 'aria-pressed': true, variant: 'secondary', children: '팔로잉' },
};
export const Disabled: Story = {
args: { disabled: true },
};
한 화면에 늘어놓는 장면도 하나 만들어둡니다.
export const AllVariants: Story = {
render: () => (
<div className="flex flex-wrap items-center gap-2">
<Button variant="default">default</Button>
<Button variant="outline">outline</Button>
<Button variant="secondary">secondary</Button>
<Button variant="ghost">ghost</Button>
<Button variant="destructive">destructive</Button>
<Button variant="link">link</Button>
</div>
),
};
여섯이 전부 다른 색으로 나란히 뜹니다. 높이는 다 32px 이고요.
늘어놓으니 몰랐던 게 보입니다
여기서 예상 못 한 게 두 개 나왔어요.
첫째, default 버튼 색이 우리 브랜드 파랑이 아닙니다. 거의 검정에 가까운 중립색이에요. E-7에서 우리는 포커스 테두리와 경고색을 우리 토큰으로 이어줬는데, 주요 버튼 색은 안 이었거든요. 그때는 화면에서 그 버튼을 볼 일이 없어서 몰랐습니다.
둘째, 더 놀라운 건데 우리 앱에는 버튼이 두 개 있습니다.
ui/button 변형과 크기를 갖춘 것 5 곳에서 사용
components/Button 자기 생김새가 없는 것 7 곳에서 사용
B-3에서 만든 소박한 버튼과, E-5에서 들여온 버튼이 지금까지 나란히 살아 있었던 거예요. 화면만 보면 둘 다 그럴듯해서 몰랐습니다. 카탈로그를 세우니까 드러났고요.
이게 카탈로그의 두 번째 값입니다. 가진 것을 늘어놓으면 중복이 보입니다.
💡 한 줄 정리
argTypes로 고를 수 있는 값을 적어두면 카탈로그에서 상태를 바꿔가며 볼 수 있고, 전부 늘어놓는 순간 화면만 보고 있을 때는 안 보이던 중복과 어긋남이 드러납니다.
🙋 학생 질문 — "버튼이 두 개면 지금 합쳐야 하나요?"
지금은 아닙니다. 오늘 우리가 한 일은 발견이지 정리가 아니에요.
합치려면 결정할 게 많습니다. 어느 쪽으로 모을지, 옮기는 동안 화면이 안 깨지는지, 기존 판이 몇 개나 물리는지요. F-3에서 배웠듯이 사용자에게 가까운 코드일수록 많은 판이 붙들고 있고, 이 둘은 합쳐서 열두 곳에 쓰이고 있습니다.
중요한 건 이제 목록이 생겼다는 거예요. 예전에는 "우리 버튼 어떻게 생겼더라"를 코드를 뒤져서 알아냈다면, 이제는 카탈로그를 열면 둘이 나란히 보입니다. 정리는 그다음 얘기고요.
Step 5: "이야기가 곧 판이 됩니다"
여기가 오늘의 핵심입니다.
지금까지 우리가 쓴 것은 눈으로 보는 카탈로그였는데, 이 장면들은 이미 판이 갖춰야 할 것을 다 갖고 있습니다. 무엇을 그릴지 정해져 있고, 어떤 값을 줄지 정해져 있죠. 그대로 돌려서 안 터지는지 보면 그게 판입니다.
Step 1에서 설치를 살려낸 그 애드온이 이 일을 합니다. 설정을 이렇게 바꿔요.
// apps/web-spa/vitest.config.ts
export default mergeConfig(
viteConfig,
defineConfig({
test: {
// 판이 도는 곳이 두 군데가 됐다.
// unit 은 jsdom 에서 흉내 낸 DOM 으로, storybook 은 진짜 브라우저로 돈다.
projects: [
{
extends: true,
test: {
name: 'unit',
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
exclude: [...configDefaults.exclude, 'e2e/**'],
},
},
{
extends: true,
plugins: [storybookTest({ configDir: path.join(dirname, '.storybook') })],
test: {
name: 'storybook',
browser: {
enabled: true,
headless: true,
provider: playwright({}),
instances: [{ browser: 'chromium' }],
},
},
},
],
},
}),
);
판이 도는 곳이 두 군데가 됐습니다. 하나는 지금까지처럼 jsdom에서 돌고, 다른 하나는 진짜 크로미움에서 돕니다. 지난 시간에 깐 브라우저를 그대로 씁니다.
두 번째 것에는 우리가 판을 한 줄도 안 적었는데, 플러그인이 카탈로그 설정을 읽어서 등록된 장면을 전부 판으로 만듭니다.
돌려봅시다.
npm test
Test Files 155 passed (155)
Tests 1073 passed (1073)
착수할 때가 152 파일 / 1061 판이었으니 파일 3개, 판 12개가 늘었는데, 12는 우리가 쓴 장면 수와 정확히 같아요. 아바타 4 + 댓글 입력 2 + 버튼 6입니다.
주목할 게 하나 더 있습니다. 따로 명령을 안 불렀어요. 늘 쓰던 npm test가 브라우저 쪽까지 함께 돌았습니다.
지난 시간에 우리는 반대 상황을 만났죠. E2E 판 파일 이름이 Vitest가 찾는 이름과 겹쳐서 일부러 빼둬야 했습니다. 이번엔 그 반대예요. 빼는 게 아니라 저절로 합류합니다.
💡 한 줄 정리
카탈로그에 써둔 장면은 그 자체로 판이 갖출 것을 다 갖고 있습니다. 배선 한 번이면 따로 판을 쓰지 않고도 이야기 수만큼 판이 늘어나고, 그것도 진짜 브라우저에서 돕니다.
🙋 학생 질문 — "이 판은 뭘 확인하는 건가요? 아무것도 안 적었는데요."
좋은 질문입니다. 지금 이 판이 확인하는 건 하나예요. 그리다가 안 터진다.
우습게 들릴 수 있는데 생각보다 많이 잡습니다. 필수 값을 안 준 채로 컴포넌트를 바꾸면 여기서 터지고, import를 잘못 지워도 터지고, 훅 규칙을 어겨도 터집니다. F-1에서 봤던 것처럼 아무 판도 없는 것보다는 훨씬 낫고요.
그리고 다음 두 Step에서 여기에 살을 붙입니다. Step 6에서는 눌러보게 하고, Step 7에서는 접근성까지 보게 만들 거예요. 같은 이야기 하나에 확인이 세 겹으로 쌓입니다.
Step 6: "눌러보는 이야기"
그리는 것만으로는 부족한 컴포넌트가 있습니다. 댓글 폼이 그렇죠. 빈 칸일 때 버튼이 잠기고, 글자를 치면 열리고, 누르면 내용이 넘어가야 합니다.
이야기에 play를 붙이면 그 장면이 그려진 뒤에 손이 하나 들어옵니다.
// apps/web-spa/src/components/CommentForm.stories.tsx
import { expect, fn, userEvent, within } from 'storybook/test';
import { CommentForm } from './CommentForm';
const meta = {
title: '카탈로그/CommentForm',
component: CommentForm,
args: { onSubmit: fn() },
} satisfies Meta<typeof CommentForm>;
export const TypeAndSubmit: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText('댓글 입력');
const submit = canvas.getByRole('button', { name: '게시' });
await expect(submit).toBeDisabled();
await userEvent.type(input, '사진 좋네요');
await expect(submit).toBeEnabled();
await userEvent.click(submit);
await expect(args.onSubmit).toHaveBeenCalledWith('사진 좋네요');
await expect(input).toHaveValue('');
},
};
안에 적힌 것을 보세요. getByLabelText, getByRole, userEvent, expect. F-3에서 배운 그대로입니다. 새로 배울 문법이 없어요. 달라진 건 가져오는 곳 하나뿐입니다.
onSubmit은 fn()으로 줍니다. 진짜 어디로 보내는 대신 무슨 값으로 불렸는지 기억하는 가짜예요. F-2에서 쓰던 그것과 같습니다.
공백만 쳤을 때 안 보내는 것도 확인해둡니다.
export const WhitespaceOnly: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText('댓글 입력');
await userEvent.type(input, ' ');
await expect(canvas.getByRole('button', { name: '게시' })).toBeDisabled();
await expect(args.onSubmit).not.toHaveBeenCalled();
},
};
이 판이 진짜로 무언가를 지키는지 확인합니다
판을 만들었으면 그게 정말 무언가를 붙들고 있는지 봐야죠. 일부러 깨봅시다. 댓글 입력 훅에서 공백을 털어내는 부분을 빼면 어떻게 될까요.
// apps/web-spa/src/hooks/useCommentInput.ts
// 원래: const isEmpty = content.trim() === '';
const isEmpty = content === '';
이러면 공백만 쳐도 빈 값이 아니게 되는데, 돌려보면 네 건이 웁니다.
FAIL |storybook| CommentForm.stories.tsx > Whitespace Only
FAIL |unit| b2-form.test.tsx > 공백만 쳐서는 게시 버튼이 살아나지 않는다
FAIL |unit| f3-comment-form.test.tsx > 공백만 넣으면 잠긴 채로 있다
FAIL |unit| b3-use-comment-input.test.tsx > 빈 입력은 제출을 막는다
첫 줄이 오늘 만든 이야기고, 나머지 셋은 B-2와 F-3와 B-3에서 쓴 판들입니다. 같은 결함을 같이 잡았어요.
이게 중요한 이유가 있습니다. 카탈로그는 자칫 "보기 좋으라고 만든 것"으로 취급되기 쉬운데, 지금 이 이야기는 기존 판들과 나란히 서서 같은 일을 하고 있습니다. 장식이 아닙니다.
💡 한 줄 정리
play를 붙이면 이야기가 눌러보는 판이 됩니다. 문법은 F-3에서 배운 그대로이고, 일부러 코드를 깨보면 이 판이 기존 판들과 같은 결함을 함께 잡는 것을 확인할 수 있습니다.
🙋 학생 질문 — "같은 걸 확인하는 판이 네 개나 있으면 낭비 아닌가요?"
넷이 같은 걸 확인하는 건 맞는데, 보는 각도가 다릅니다.
B-3의 판은 훅 하나만 떼어서 봅니다. B-2와 F-3의 판은 컴포넌트를 jsdom에 그려놓고 보고요. 오늘 것은 진짜 브라우저에서 봅니다. 그리고 오늘 것만 눈으로도 볼 수 있어요 — 카탈로그를 열면 그 장면이 거기 있습니다.
F-1에서 봤던 테스팅 트로피를 떠올려보세요. 층이 여러 개인 이유가 이겁니다. 아래층은 빠르고 좁게, 위층은 느리고 넓게 봅니다.
다만 여러분 말에도 일리가 있어요. 새로 만들 때 넷을 다 만들 필요는 없습니다. 오늘 우리는 이미 있는 것 위에 하나를 더한 것이고, 새 컴포넌트라면 하나만 골라 시작하는 게 맞습니다.
Step 7: "접근성은 켜야 잡습니다"
Step 1에서 설치가 실패했던 애드온 하나가 접근성 검사였죠. 이제 살아났으니 켜봅시다.
설정을 열어보면 도구가 이렇게 적어뒀습니다.
// apps/web-spa/.storybook/preview.tsx
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo'
}
기본값이 'todo'입니다. 주석이 친절하게 설명해주네요. 위반을 화면에 보여주기만 하고 판은 통과시킨다고요.
잠깐 생각해봅시다. 이게 켜진 걸까요, 꺼진 걸까요?
돌려보면 판은 전부 초록입니다. 아무 일도 안 일어나요. 그런데 위반이 없어서 초록인 게 아니라 위반이 있어도 초록입니다. 우리가 Step 1에서 만난 그 상황과 똑같아요. 조용한 것과 문제가 없는 것은 다릅니다.
'error'로 바꿉니다.
a11y: {
test: 'error',
},
켜자마자 다섯 건
FAIL avatar.stories.tsx > Default / Large / Small / No Image
"Elements must meet minimum color contrast ratio thresholds (color-contrast)"
FAIL button.stories.tsx > All Variants
"Elements must meet minimum color contrast ratio thresholds (color-contrast)"
전부 색 대비 문제인데, 이야기의 문제가 아니라 우리 컴포넌트의 문제예요.
숫자로 재봅시다. 글자와 배경의 밝기 차이를 재는 기준이 있고, 본문 글자는 4.5를 넘어야 합니다.
| 대비 | 기준 통과 | |
|---|---|---|
| 아바타 이름 글자 (밝은 화면) | 4.34 | 미달 |
| 아바타 이름 글자 (어두운 화면) | 5.83 | 통과 |
| destructive 버튼 (밝은 화면) | 3.26 | 미달 |
하나는 고치고 하나는 안 고칩니다
아바타 쪽부터요. 이건 사진이 안 뜬 아바타의 이름 글자입니다. Step 2에서 봤던 그 장면이죠. 사용자가 실제로 보는 화면이에요. 게다가 이 색은 우리가 고른 게 아니라 E-5에서 컴포넌트를 들여올 때 딸려온 기본값입니다.
한 칸만 어둡게 합니다.
/* apps/web-spa/src/styles/globals.css */
--muted-foreground: oklch(0.54 0 0);
4.34에서 4.64가 됐습니다. 어두운 화면은 이미 5.83이라 안 건드립니다.
버튼 쪽은 다릅니다. 이 destructive 변형을 우리 앱은 한 군데도 안 씁니다. 카탈로그에서 여섯을 늘어놓으니까 드러난 거예요.
게다가 답도 간단하지 않습니다. E-7에서 우리가 만들어둔 좀 더 진한 빨강으로 바꿔도 4.18이라 여전히 미달이에요. 배경이 같은 빨강을 아주 연하게 깐 것이라, 글자가 훨씬 더 어두워져야 합니다. 색 체계를 다시 손대야 하는 일이라서, 지금은 안 고치고 표시만 해둡니다.
// apps/web-spa/src/components/ui/button.stories.tsx
export const AllVariants: Story = {
parameters: {
a11y: { test: 'todo' },
},
render: () => (
// ...
),
};
아까 그 'todo'입니다. 이번엔 전체 기본값이 아니라 이 장면 하나에만 붙였는데, 그 차이로 뜻이 완전히 달라집니다. 전체에 깔면 "아무도 안 본다" 지만, 골라서 붙이면 "알고 있고 아직 안 고쳤다"는 기록이 됩니다. 이 변형을 쓰기 시작하는 날 갚아야 할 빚이고요.
전부 초록으로 바뀌었는데, 이번엔 볼 게 없어서 초록인 게 아닙니다.
💡 한 줄 정리
검사를 켠다는 건 실패하게 만든다는 뜻입니다. 보여주기만 하는 설정은 아무도 안 고치게 두고, 실제로 켜면 우리가 몰랐던 것이 바로 나옵니다. 나온 걸 다 고쳐야 하는 건 아니고, 안 고칠 거라면 그 사실을 코드에 남깁니다.
🙋 학생 질문 — "안 쓰는 변형인데 카탈로그에서 아예 빼면 안 되나요?"
뺄 수도 있습니다. 그러면 판도 초록이 되고 위반도 사라져요.
그런데 그건 문제를 없앤 게 아니라 안 보이게 만든 것입니다. 그 변형은 코드에 그대로 있고, 언젠가 누가 쓰게 되고, 그때는 아무도 이 대비 문제를 모르는 상태예요.
지금 방식은 반대입니다. 카탈로그에 그대로 두니까 누구든 열면 보이고, 코드에는 "알고 있다"는 표시와 이유가 적혀 있습니다.
정리하면 선택지가 셋이에요. 고친다 / 표시하고 남긴다 / 지운다. 안 쓰는 코드라면 사실 세 번째도 진지하게 검토할 만한데, 그건 F-2에서 다뤘던 이야기죠. 오늘 우리는 두 번째를 골랐습니다.
Step 8: "지난 시간의 목록을 다시 씁니다"
마지막입니다. 지난 시간 마무리의 그 약속을 갚을 차례예요.
다음 시간에는 컴포넌트를 카탈로그로 만듭니다. 지난 시간에 만든 목록이 거기서도 한 번 더 쓰입니다.
지금까지 만든 이야기들은 목록이 필요 없었는데, 아바타도 버튼도 댓글 입력도 전부 값을 받아서 그리기만 하거든요. 네트워크를 안 탑니다.
목록이 필요하려면 서버에 물어보는 화면을 세워야 해요. 홈 피드가 그렇습니다.
// apps/web-spa/src/routes/HomePage.stories.tsx
const meta = {
title: '카탈로그/화면 - 홈 피드',
component: HomePage,
decorators: [
(Story) => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={client}>
<MemoryRouter>
<Story />
</MemoryRouter>
</QueryClientProvider>
);
},
],
} satisfies Meta<typeof HomePage>;
decorators가 새로 나왔습니다. 이야기를 감싸는 껍데기예요. 이 화면은 혼자 못 섭니다. 서버에 물어보려면 창고가 필요하고, 카드 안에서 주소를 읽으니 라우터도 필요해요. 앱이 씌워주던 것을 여기서 씌웁니다.
목록을 잇습니다
흉내 서버도 붙입니다. F-4에서 만든 그것을 여기서 그대로 가져와요.
// apps/web-spa/.storybook/preview.tsx
import { worker } from '../src/mocks/browser';
export default definePreview({
addons: [
addonMsw(async () => {
await worker.start({
quiet: true,
onUnhandledRequest: 'bypass',
});
return worker;
}),
],
});
worker는 F-4에서 만든 겁니다. 이미 우리 목록을 품고 있는 그 객체예요. 새로 만드는 게 아니라 있는 걸 건네줍니다.
목록에 없는 요청은 그냥 내보냅니다. 카탈로그에는 프로필 사진처럼 우리 API가 아닌 요청이 섞여 있어서, 판에서 쓰던 엄격한 설정을 그대로 가져오면 사진이 전부 막혀요.
좋아요 요청을 받는 자리는 지난 시간 과제로 남겨뒀던 곳이라 이번에 채웁니다.
// apps/web-spa/src/mocks/handlers.ts
http.post(`${MOCK_API_BASE}/posts/:postId/like`, ({ params }) => {
const id = Number(params.postId);
const post = allPosts.find((item) => item.id === id);
if (post === undefined) {
return HttpResponse.json(failure('없는 게시물이에요'), { status: 404 });
}
const current = likeState.get(id) ?? { liked: post.liked, likeCount: post.likeCount };
const next = {
liked: !current.liked,
likeCount: current.likeCount + (current.liked ? -1 : 1),
};
likeState.set(id, next);
return HttpResponse.json(ok({ id, ...next }));
}),
하트를 눌러봅니다
export const LikeRoundTrip: Story = {
beforeEach: () => {
resetLikeState();
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findAllByRole('article');
const [heart] = canvas.getAllByRole('button', { name: '좋아요' });
await expect(heart).toHaveAttribute('aria-pressed', 'false');
await userEvent.click(heart);
await expect(heart).toHaveAttribute('aria-pressed', 'true');
await expect(await canvas.findByText('좋아요 1241개')).toBeInTheDocument();
},
};
앱을 안 켜고, 진짜 서버도 안 켜고, 카탈로그 안에서 하트를 눌러 숫자가 올라가는 것까지 확인합니다.
흉내 서버가 두 곳에서 다른 말을 하고 있었습니다
처음 이걸 만들었을 때 숫자가 안 올라갔는데, 요청은 분명히 나갔고 200도 받았지만 화면은 1240 그대로였습니다.
원인은 이랬습니다. 좋아요 요청을 받는 쪽은 뒤집힌 걸 기억하는데, 목록을 돌려주는 쪽이 원래 값을 그대로 주고 있었어요. 누르면 잠깐 1241이 됐다가, 앱이 목록을 다시 물어보는 순간 1240으로 되돌아간 겁니다.
F-4에서 배운 걸 떠올려보세요. 핸들러는 사실이 아니라 우리가 적어둔 주장입니다. 주장이 두 개인데 서로 안 맞으면 이런 일이 생기니까, 목록을 줄 때도 기억한 걸 얹어서 주도록 고쳤습니다.
그리고 목록이 지켜지고 있었습니다
다 끝내고 돌려보니 숫자가 이렇게 나왔어요.
Test Files 157 passed (157)
Tests 1080 passed (1080)
착수할 때가 152 / 1061 이었으니 파일 5개, 판 19개가 늘었는데, 우리가 쓴 이야기는 18개예요. 하나가 안 맞습니다.
찾아보니 F-4에서 만든 판 하나가 늘어난 것이었습니다. 그 판은 목록에 적힌 주소를 하나씩 진짜 연습용 서버의 주소와 대조하는데, it.each로 짜여 있어서 핸들러를 더하면 확인도 한 건 늘어납니다.
그게 통과했다는 건, 우리가 오늘 적어둔 좋아요 주소가 진짜 서버에도 실제로 있다는 뜻이에요.
목록이 한 번 더 쓰인 정도가 아닙니다. 지켜지고 있었습니다.
💡 한 줄 정리
카탈로그에 화면을 세우면 흉내 서버가 필요해지고, 그때 쓰는 목록은 판이 쓰던 그 목록입니다. 한 곳에 적어둔 주장이 판과 개발 화면과 카탈로그 세 곳에서 함께 쓰이고, 그 주장이 맞는지 검사하는 판도 따라옵니다.
🙋 학생 질문 — "설정을 그냥 객체로 내보내면 안 되나요? definePreview는 왜 쓰죠?"
여기서 저도 한참 헤맸습니다.
처음에는 그냥 객체를 내보냈어요. 타입 검사도 통과하고, 오류도 없고, 경고도 없었습니다. 그런데 흉내 서버가 아무것도 가로채지 않았어요. 브라우저에서 확인해보니 서비스 워커 등록이 0건 이었습니다.
원인은 이겁니다. 그냥 객체를 내보내면 addons에 적은 게 조용히 무시됩니다. definePreview()로 감싸야 실제로 붙어요.
이 회차에서 가장 조용한 실패였습니다. Step 1의 설치는 그래도 중간에 실패 줄이라도 있었는데, 이건 아무 데도 아무 말이 없어요. 화면은 그냥 "불러오는 중"에 멈춰 있고요.
여러분이 앞으로 만날 문제도 대개 이런 모양일 겁니다. 오류가 나면 오히려 다행이고, 진짜 어려운 건 아무 말도 없는데 안 되는 것이에요.
마무리
오늘 우리는 컴포넌트를 앱에서 떼어내 늘어놓았는데, 그게 보기 좋으라고 한 일이 아니었어요. 늘어놓는 순간 몰랐던 것들이 나왔고, 늘어놓은 것이 그대로 판이 됐습니다.
오늘 배운 핵심 세 가지
💡 하나 — 이야기 하나가 세 가지 일을 합니다. 팀원에게는 문서가 되고, 앱을 안 켜고 컴포넌트만 보는 개발 화면이 되고, 그대로 판이 됩니다. 우리가 쓴 18개의 이야기가 그대로 18개의 판이 됐어요. 판을 따로 쓴 게 아닙니다.
💡 둘 — 늘어놓으면 몰랐던 게 보입니다. 버튼이 두 개라는 것, 주요 버튼 색이 우리 브랜드색이 아니라는 것, 사진이 안 뜬 아바타의 글자 대비가 기준에 못 미친다는 것. 셋 다 화면만 보고 있을 때는 몰랐고, 카탈로그를 세우니 드러났습니다.
💡 셋 — 조용한 것은 통과가 아닙니다. 오늘 세 번 만났어요. 설치가 "완료"라고 말하면서 절반이 실패했고, 접근성 검사가 켜져 있는데 아무것도 안 잡았고, 설정에 적은 애드온이 소리 없이 무시됐습니다. 셋 다 오류도 경고도 없었습니다.
눈으로 보는 것까지 지키려면
오늘 우리가 만든 판이 못 보는 게 하나 있습니다. 보이는 모습이에요.
play는 눌러보고 확인하지만 색이 바뀐 건 모릅니다. 접근성 검사는 대비를 재지만 여백이 틀어진 건 모르고요. 하트 아이콘을 별 아이콘으로 바꿔도 오늘의 판은 전부 초록입니다.
그걸 보는 방법이 따로 있습니다. 장면마다 사진을 찍어두고, 다음에 찍은 것과 픽셀 단위로 비교하는 거예요. 달라진 곳이 있으면 사람에게 "이거 의도한 변경이 맞나요?" 하고 물어봅니다. Chromatic이 그런 일을 하는 서비스이고, 오늘 우리 설정에 이미 들어와 있는 @chromatic-com/storybook이 그것과 이어주는 애드온입니다.
이건 계정을 만들고 사진을 어딘가 올려야 돌아가는 것이라 오늘은 이름만 짚고 넘어갑니다. 중요한 건 오늘 만든 카탈로그가 그 입구라는 거예요. 장면을 늘어놓는 일을 이미 해뒀으니, 시각 비교를 붙이고 싶어지면 그 위에 얹으면 됩니다.
테스팅 여섯 시간을 돌아보면
F-1에서 우리는 이미 있던 판을 세는 것부터 시작했습니다. 954개가 이미 저장소에 쌓여 있었고, 커버리지가 가리킨 곳을 따라가며 직접 판을 썼고(F-2), 화면을 사용자처럼 조회하는 법을 배웠고(F-3), 서버를 흉내 내는 목록을 만들었고(F-4), 진짜 브라우저로 한 줄기를 밟았고(F-5), 오늘 그것들을 카탈로그 위에 다시 모았습니다.
돌아보면 여섯 시간 내내 같은 질문을 반복했어요. "이 초록불이 진짜 무언가를 지키고 있나?"
F-1에서는 커버리지 숫자를 의심했고, F-4에서는 주소를 통째로 망가뜨려도 통과하는 판을 봤고, F-5에서는 아무 일도 안 일어나는데 초록인 판을 봤습니다. 오늘은 설치 도구와 접근성 설정에까지 같은 질문을 했고요.
판을 늘리는 것보다 그 질문을 계속하는 게 훨씬 어려운데, 그게 여섯 시간 동안 진짜로 배운 것입니다.
다음 시간 예고
테스팅 트랙은 여기서 마칩니다. 다음 시간엔 무대가 다시 Next.js 쪽으로 돌아가서, 진짜 로그인을 앱에 들입니다.
과제
[구현] 게시물 카드를 카탈로그에 세워보세요
오늘 우리는 아바타와 버튼과 댓글 입력칸을 세웠습니다. 정작 인스타그램의 주인공인 카드는 안 세웠어요.
PostCard의 이야기를 만드세요. 이 컴포넌트는 받아야 할 값이 열 개입니다 — 데이터 아홉 개에 함수 하나예요.- 좋아요를 이미 누른 상태와 안 누른 상태를 각각 하나씩 만드세요.
- 댓글이 0개인 경우도 만들어보세요. 화면이 어떻게 달라지나요?
- ⚠️ 값 아홉 개를 매번 다 적으려면 번거로울 겁니다.
meta.args에 한 번만 적고 각 장면에서 달라지는 것만 덮어쓰는 방법을 써보세요.
[구현] 지난 시간에 만든 목록으로 실패하는 화면을 세워보세요
Step 8에서 우리는 목록이 잘 답하는 경우만 만들었습니다. 안 될 때도 봐야죠.
- 홈 피드 이야기를 하나 더 만들고, 그 이야기에서만 목록이 500을 주게 하세요. Step 8의
Loading이야기가 쓴 방법을 참고하면 됩니다. - 화면에 무엇이 뜨는지 확인하고 그것을 판으로 적으세요.
- ⚠️ 적을 것이 마땅치 않다면 그게 답입니다. 왜 적을 게 없는지, 그리고 그건 누구의 문제인지 한 문단으로 쓰세요.
[탐구] 접근성 검사를 앱 전체로 넓혀보세요
오늘 우리는 컴포넌트 몇 개에만 검사를 걸었습니다.
- 홈 피드 화면 이야기에도 접근성 검사가 걸립니다. 무엇이 나오는지 확인하세요.
- 나온 것들을 셋으로 나누세요. 지금 고칠 것, 표시만 해둘 것, 검사가 잘못 잡은 것.
- 세 번째가 있다면 왜 그렇게 판단했는지 근거를 적으세요.
- ⚠️ 지난 시간에 우리가 못 고치고 남겨둔 것이 하나 있었죠. 머리말을
getByRole('banner')로 못 찾는 문제요. 그 원인이Layout.tsx안에 있습니다. 접근성 검사가 이걸 잡아주는지도 확인해보세요. 잡아주든 아니든, 왜 그런지가 이 과제의 본체입니다.
[탐구] 판이 두 곳에서 도는 값을 재보세요
Step 5에서 판이 도는 곳이 두 군데가 됐습니다. 진짜 브라우저를 띄우는 쪽은 공짜가 아니에요.
- 두 곳을 각각 따로 돌려서 걸리는 시간을 재세요. 명령은
npx vitest run --project=unit과npx vitest run --project=storybook입니다. - 판 하나당 시간을 계산해서 비교하세요.
- 지난 시간에 우리가 잰 값이 있습니다. 한 줄기를 진짜 브라우저로 밟는 것이 판 하나보다 170배였죠. 오늘 것은 그 사이 어디쯤인가요?
- ⚠️ 결과가 예상과 다를 겁니다. 다르다면 원래 무엇을 예상했는지부터 적고, 왜 그렇게 예상했는지 되짚어보세요.
생각해볼 주제
1. 카탈로그는 누가 봅니까
오늘 우리는 "이야기 하나가 문서도 되고 개발 화면도 되고 판도 된다" 고 했습니다. 판이 되는 건 확실해요. 우리가 숫자로 확인했으니까요.
그런데 문서 쪽은 어떨까요. 문서는 누군가 읽어야 문서입니다. 아무도 안 열어보는 카탈로그는 그냥 판일 뿐이고요.
여러분 팀에서 이 카탈로그를 실제로 열어볼 사람은 누구일까요? 개발자만인가요, 디자이너도인가요? 그 사람들이 열어보게 하려면 무엇이 더 필요할까요? 그리고 아무도 안 열어본다면, 그래도 이걸 만들 값어치가 있을까요?
2. 안 쓰는 코드의 문제는 언제 고쳐야 할까요
Step 7에서 우리는 안 쓰는 버튼 변형의 대비 문제를 발견하고 안 고치기로 했습니다. 표시만 남겼죠.
그런데 이 판단이 항상 옳을까요? 안 쓰는 코드라도 누군가는 언젠가 씁니다. 그리고 그때 그 사람은 카탈로그에 "알고 있다" 고 적힌 주석을 안 볼 수도 있어요.
반대로 지금 고치자니, 쓰지도 않는 것 때문에 색 체계를 건드리는 게 맞나 싶고요.
세 번째 선택지도 있습니다. 아예 지우는 거예요. 셋 중 무엇을 고르시겠어요? 그리고 그 결정을 무엇으로 판단하시겠어요?
3. 늘어놓는 것과 쓰는 것은 어디서 갈릴까요
오늘 카탈로그를 세우니 우리 앱에 버튼이 두 개라는 게 드러났습니다. 화면만 보고 있을 때는 몰랐던 거죠.
그런데 카탈로그가 보여주는 건 "무엇이 있는가"입니다. "무엇이 어디에 쓰이는가"는 아니에요. 버튼 두 개가 카탈로그에 나란히 있어도, 어느 것을 언제 써야 하는지는 카탈로그가 안 알려줍니다.
그럼 그건 어디에 적어야 할까요? 카탈로그 안에 적을 수 있을까요, 아니면 다른 곳이 필요할까요? 그리고 그 규칙이 지켜지는지는 무엇으로 확인할 수 있을까요?
✅ 예시 답안정답 보기
과제 예시답안
🎯 [과제 1 예시답안] 게시물 카드를 카탈로그에 세워보세요
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 값을 한 곳에 모았는가 | 6 | meta.args에 한 벌만 적고 각 이야기는 달라지는 것만 덮어썼는가 |
| 세 장면을 만들었는가 | 6 | 기본 · 좋아요 누른 것 · 댓글 0개 |
| 안 서는 이유를 찾았는가 | 8 | 값을 다 줬는데도 오류가 났고, 그 원인이 카드 안쪽에 있다는 것을 짚었는가 |
| 댓글 0개일 때를 관찰했는가 | 5 | 화면이 어떻게 달라지는지 직접 확인했는가 |
| 합계 | 25 |
풀이 예시
// apps/web-spa/src/components/PostCard.stories.tsx
import { MemoryRouter } from 'react-router';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { PostCard } from './PostCard';
const meta = {
title: '카탈로그/PostCard',
component: PostCard,
decorators: [
(Story) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
args: {
id: 1,
username: 'jaehoon',
profileImageUrl: 'https://picsum.photos/seed/jaehoon/64/64',
imageUrl: 'https://picsum.photos/seed/post1/640/640',
mediaKind: 'image',
content: '오늘 한강 노을이 미쳤다',
likeCount: 1240,
commentCount: 32,
liked: false,
onToggleLike: fn(),
},
} satisfies Meta<typeof PostCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Liked: Story = {
args: { liked: true, likeCount: 1241 },
};
export const NoComments: Story = {
args: { commentCount: 0 },
};
이 과제의 본체 — 값을 다 줘도 안 섭니다
값 열 개를 정확히 적고 나면 타입 검사는 통과하는데, 돌려보면 이렇게 터져요.
useLocation() may be used only in the context of a <Router> component.
❯ useSearchParams src/components/PostBody.tsx:39:42
카드 안쪽의 PostBody가 주소를 직접 읽고 있습니다. C-4에서 우리가 그렇게 옮겼죠. 그때 이유가 있었어요. 모달을 열지 말지는 주소에 적혀 있고, 주소는 누구나 물어볼 수 있으니 위에서 네 단계에 걸쳐 배달할 이유가 없다고요.
그 결정이 여기서 대가를 드러내는데, 밖에서 보면 값만 받는 컴포넌트인데 실제로는 아니라서, 카탈로그에 세워보기 전까지는 이 사실이 안 보입니다.
MemoryRouter로 감싸주는 이유가 그겁니다. 이건 잘못된 설계라서가 아니라, 그 컴포넌트가 무엇에 기대고 있는지를 카탈로그가 드러낸 것뿐이에요.
자주 나오는 실수
mediaKind를 빠뜨립니다. 화면에 안 보이는 값이라 잊기 쉬워요. 빠뜨리면 이렇게 됩니다.
error TS2322: Type '{}' is not assignable to type 'StoryAnnotations$1<...
{ onToggleLike: ...; id: number; content: string; likeCount: number;
commentCount: number; liked: boolean; username: string; imageUrl: string;
mediaKind: MediaKind; profileImageUrl: string; }, ...
세 이야기가 한꺼번에 빨개집니다. meta.args에 모아뒀으니까요. 처음엔 당황스럽지만 오히려 좋은 신호예요. 값을 한 곳에 모았다는 증거고, 에러 메시지가 필요한 값 열 개를 전부 나열해줍니다.
💡 튜터의 한마디
댓글 0개 장면을 꼭 만들어보세요. "댓글 32개 모두 보기" 줄이 사라집니다. 앱을 돌리면서 이 상태를 보려면 댓글이 없는 게시물을 찾아 들어가야 하는데, 카탈로그에서는 숫자 하나만 바꾸면 됩니다. 그 장면이 그대로 판이 되니까, 나중에 누가 그 조건을 잘못 건드리면 빨개지고요.
🎯 [과제 2 예시답안] 지난 시간에 만든 목록으로 실패하는 화면을 세워보세요
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 그 이야기에서만 바꿨는가 | 7 | msw.use()로 이 장면에만 적용했는가 (전체 목록을 고치지 않았는가) |
| 실제로 무엇이 뜨는지 확인했는가 | 8 | 짐작이 아니라 화면 글자를 직접 확인했는가 |
| 적을 게 없다는 것을 알아챘는가 | 6 | 실패 화면이 따로 없다는 사실에 도달했는가 |
| 누구의 문제인지 판단했는가 | 4 | 컴포넌트 문제인지 카탈로그 문제인지 근거를 들었는가 |
| 합계 | 25 |
풀이 예시
// apps/web-spa/src/routes/HomePage.stories.tsx 에 더합니다
export const Failed: Story = {
beforeEach: async ({ msw }) => {
msw.use(
http.get(`${MOCK_API_BASE}/posts`, () =>
HttpResponse.json(
{ success: false, data: null, message: '피드를 불러오지 못했어요' },
{ status: 500 },
),
),
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryAllByRole('article')).toHaveLength(0);
},
};
화면에 무엇이 뜨나요
카드는 0장인데, 여기까지는 예상대로이고, 화면에 남은 글자를 찍어보면 이렇습니다.
"피드를 불러오는 중이에요…"
오류 화면이 아니라 불러오는 중이고, 영원히 그대로예요.
왜 적을 게 없을까요
HomePage를 다시 봅시다.
const { data: posts, isSuccess } = useFeedQuery();
if (!isSuccess) {
return <p className="text-sm text-faint">피드를 불러오는 중이에요…</p>;
}
갈래가 둘뿐입니다. 성공했거나, 아니거나. 실패는 따로 안 다뤄요.
C-7에서 우리가 그렇게 정했기 때문입니다. 실패는 이 화면이 처리하지 않고 위로 던지기로 했고, 그래서 갈래가 하나 줄었고 코드가 그만큼 단순해졌습니다.
그 결정의 대가가 여기서 보입니다. 위로 던지려면 받아줄 사람이 있어야 하는데 카탈로그에는 없어요. 앱에서는 라우터가 오류 화면을 그려주지만, 카탈로그는 컴포넌트 하나만 떼어 세운 자리라 그 위가 비어 있습니다.
그래서 누구의 문제인가요
셋 다 답이 될 수 있고, 근거만 있으면 맞습니다.
컴포넌트 문제라고 볼 수 있어요. 실패했는데 "불러오는 중"이라고 말하는 건 거짓말이니까요. 사용자가 보면 기다리면 될 줄 압니다.
카탈로그 설정 문제라고 볼 수 있어요. 앱에는 받아주는 쪽이 있는데 카탈로그에만 없는 거니까, 장식으로 오류 경계를 하나 씌워주면 앱과 같아집니다.
문제가 아니라고 볼 수도 있어요. 이 컴포넌트는 애초에 성공만 다루기로 한 것이고, 실패 화면은 다른 컴포넌트의 일이니까, 그 화면 쪽을 따로 카탈로그에 세우면 됩니다.
💡 튜터의 한마디
저는 세 번째로 기울지만, 중요한 건 답이 아니라 이 질문이 생겼다는 것입니다. 앱만 돌려서는 이 질문에 도달하기 어려워요. 실패 화면을 보려면 서버를 죽여야 하고, 죽이면 다른 것도 같이 죽으니까요.
카탈로그는 조건 하나만 바꿔 세울 수 있어서 이런 구멍이 눈에 띕니다.
🎯 [과제 3 예시답안] 접근성 검사를 앱 전체로 넓혀보세요
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 화면 이야기를 세웠는가 | 6 | 껍데기를 카탈로그에 올려 검사가 실제로 돌게 했는가 |
| 나온 것을 갈랐는가 | 6 | 고칠 것 · 표시할 것 · 잘못 잡은 것으로 나누고 근거를 댔는가 |
| 머리말 문제를 확인했는가 | 8 | 검사가 잡는지 직접 재보고 결과를 설명했는가 |
| 왜 그런지 설명했는가 | 5 | 규칙과 도구의 관계를 짚었는가 |
| 합계 | 25 |
껍데기부터 안 섭니다
세우려고 하면 검사 이전에 이렇게 터집니다.
useThemeContext 는 ThemeProvider 안에서 불러야 합니다
❯ useThemeContext src/contexts/ThemeContext.tsx:29:10
C-3에서 우리가 만든 그 훅입니다. 값이 없으면 조용히 기본값으로 넘어가지 않고 던지게 해뒀죠. 그때 이유가 "죽은 토글을 그리며 초록으로 통과하는 것을 막으려고" 였습니다.
그 약속이 카탈로그에서도 그대로 걸립니다. 감싸주면 섭니다.
// apps/web-spa/src/routes/Layout.stories.tsx
decorators: [
(Story) => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return (
<QueryClientProvider client={client}>
<ThemeProvider>
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<Story />}>
<Route index element={<p>여기가 화면이 갈리는 자리입니다</p>} />
</Route>
</Routes>
</MemoryRouter>
</ThemeProvider>
</QueryClientProvider>
);
},
],
접근성 검사는 머리말 문제를 안 잡습니다
돌려보면 위반이 0건이고, 지난 시간에 못 고친 그 문제를 안 잡습니다.
이유는 그게 위반이 아니기 때문입니다. axe는 "이러면 사용자가 못 쓴다"를 잡습니다. 색이 안 보인다거나, 버튼에 읽어줄 이름이 없다거나요. 머리말이 banner라는 이름표를 갖느냐 마느냐는 구조를 어떻게 짤 것인가의 선택이지 못 쓰는 상태가 아니에요.
그런데 여기서 더 이상한 게 나옵니다
지난 시간에 getByRole('banner')가 0 이었는데, 같은 걸 여기서도 재봤습니다.
const header = canvasElement.querySelector('main > header');
await expect(header).not.toBeNull();
await expect(canvas.queryAllByRole('banner')).toHaveLength(1);
1입니다. 구조는 지난 시간과 똑같아서 <header>는 여전히 <main> 안에 있는데, 개수가 다릅니다.
| 도구 | banner 개수 |
|---|---|
| Playwright (지난 시간) | 0 |
| Testing Library (오늘) | 1 |
HTML 규칙은 header가 main·article·aside·nav·section 안에 있으면 banner 역할을 잃는다고 정합니다. Playwright 는 그 규칙을 따르고 Testing Library는 안 따릅니다.
그래서 셋으로 가르면
지금 고칠 것 — 없습니다. 색 대비는 지난 Step에서 이미 처리했고, 나머지는 검사가 잡은 게 없어요.
표시만 해둘 것 — 머리말 구조입니다. <header>를 <main> 밖으로 빼면 두 도구가 같은 답을 하게 되지만, 그러면 화면 전체의 배치가 바뀝니다. 지금 당장 급하지 않으니 알고 있다는 표시를 남깁니다.
검사가 잘못 잡은 것 — 없는데, 검사가 못 잡는 것은 있어요. 위의 머리말 문제가 그렇습니다.
💡 튜터의 한마디
여기서 진짜 배울 것은 머리말이 아니라 도구가 서로 다른 답을 준다는 사실입니다.
지난 시간에 우리는 Playwright의 0을 보고 "역할을 잃었구나"라고 결론 냈어요. 그 결론은 규칙에 비춰 맞는데, 오늘 다른 도구는 1이라고 말해요.
둘 중 하나가 틀린 게 아니라, 규칙을 어디까지 구현했느냐가 다른 거예요. 마크업이 애매한 채로 있으면 이런 어긋남이 계속 생기는데, 명확하게 짜두면 어느 도구로 재도 같은 답이 나오고요. 그게 이 문제를 언젠가 고쳐야 하는 진짜 이유입니다.
🎯 [과제 4 예시답안] 판이 두 곳에서 도는 값을 재보세요
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 두 곳을 따로 쟀는가 | 6 | 각각 실행해 시간과 판 수를 얻었는가 |
| 여러 번 쟀는가 | 5 | 한 번만 재고 결론 내지 않았는가 |
| 판 하나당으로 환산했는가 | 6 | 총 시간이 아니라 나눠서 비교했는가 |
| 나눗셈의 한계를 알아챘는가 | 8 | 고정비 때문에 이 비교가 오해를 부른다는 것을 짚었는가 |
| 합계 | 25 |
풀이 예시
npx vitest run --project=unit
npx vitest run --project=storybook
세 번씩 쟀습니다.
| 회차 | unit | storybook |
|---|---|---|
| 1 | 16.46초 / 1062판 | 5.13초 / 22판 |
| 2 | 16.51초 / 1062판 | 5.13초 / 22판 |
| 3 | 16.53초 / 1062판 | 5.14초 / 22판 |
값이 거의 안 흔들립니다. 판 하나당으로 나누면 이렇게 됩니다.
| 판 하나당 | |
|---|---|
| unit (흉내 낸 DOM) | 15.5ms |
| storybook (진짜 브라우저) | 233ms |
약 15배입니다.
예상과 무엇이 다른가요
지난 시간에 우리는 한 줄기를 진짜 브라우저로 밟는 것이 판 하나보다 170배라고 쟀으니, 오늘 것도 그쯤 나올 줄 알았어요. 같은 브라우저를 띄우니까요.
15배입니다. 열 배 넘게 쌉니다.
그런데 이 나눗셈은 오해를 부릅니다
5.13초 안에서 진짜로 판을 도는 시간은 얼마 안 되고, 대부분은 브라우저를 띄우고 흉내 서버를 시작하는 데 씁니다. 그건 판이 22개든 220개든 한 번만 내는 값이에요.
그러니 "판 하나에 233ms"는 틀린 말은 아닌데 잘못 읽히기 쉽습니다. 판을 열 배로 늘려도 시간이 열 배가 되지 않아요. 고정비가 22개에 나눠 실리고 있을 뿐입니다.
지난 시간의 170배도 같은 이유로 조심해서 읽어야 합니다. 그쪽은 판 하나마다 화면을 새로 띄우고 로그인부터 다시 밟았거든요. 고정비를 매번 냈습니다.
정확한 비교는 이렇게 정리됩니다.
- 흉내 낸 DOM은 시작하는 값이 거의 없고 판마다 값도 싸다
- 진짜 브라우저는 시작하는 값이 비싸고 판마다 값은 생각보다 안 비싸다
- 진짜 브라우저로 잴 거면 한 번 띄워서 여러 개를 도는 쪽이 유리하다
오늘 우리가 만든 게 정확히 그 모양입니다. 브라우저 한 번 띄워서 22개를 돌았어요.
💡 튜터의 한마디
숫자를 나눠본 뒤에 "그래서 이 나눗셈이 말이 되나"를 한 번 더 묻는 습관을 들이세요.
이 과목에서 계속 해온 일이 그겁니다. 커버리지 숫자를 나눠보고 의심했고, 번들 크기를 비율로 볼지 절대값으로 볼지 따졌고, 오늘은 판 하나당 시간이 진짜 판 하나의 값인지를 물었습니다.
숫자는 거짓말을 안 하는데, 나누는 방법이 거짓말을 합니다.
생각해볼 주제 예시답안
🤔 [생각해볼 주제 1] 카탈로그는 누가 봅니까
문제 상황 요약
"이야기 하나가 문서도 되고 개발 화면도 되고 판도 된다" 고 했습니다. 판이 되는 건 숫자로 확인했어요. 그런데 문서는 누군가 읽어야 문서입니다. 아무도 안 여는 카탈로그는 그냥 판일 뿐이죠.
튜터의 가이드 및 해설
먼저 솔직해집시다. 많은 팀에서 카탈로그는 실제로 안 열립니다. 만들 때는 다들 좋다고 하는데 두 달 뒤에 보면 아무도 안 봐요.
왜 그런지 생각해보면 이유가 몇 가지 있습니다.
최신이 아니면 안 봅니다. 컴포넌트를 고쳤는데 이야기를 안 고치면 카탈로그가 거짓말을 시작해요. 한 번 거짓말한 문서는 다시 안 읽히는데, 오늘 우리가 한 배선이 이걸 막아줍니다. 이야기가 판이니까 컴포넌트를 고치면 빨개지거든요. 판이 되게 만든 것이 문서를 살리는 장치이기도 합니다.
찾을 수 없으면 안 봅니다. 이야기가 200개인데 이름이 제각각이면 원하는 걸 못 찾아요. 오늘 우리가 카탈로그/라는 앞머리를 붙인 게 그 시작입니다. 팀이 커지면 여기에 규칙이 더 필요해집니다.
나에게 필요 없으면 안 봅니다. 개발자는 코드를 직접 읽으면 되니까 카탈로그가 덜 급해요. 정작 필요한 사람은 따로 있습니다. 디자이너가 "이 버튼 눌린 상태 어떻게 생겼죠?"를 물을 때, 새로 온 사람이 "우리 앱에 버튼이 몇 종류죠?"를 물을 때요.
값어치는 이렇게 정리해볼 수 있습니다. 아무도 안 열어봐도 판으로서의 값은 그대로 남습니다. 오늘 우리가 만든 22개는 계속 돌아요. 문서로서의 값은 덤이고, 그 덤을 챙기려면 사람을 끌어올 이유를 따로 만들어야 합니다.
🎯 면접관을 홀리는 핵심 멘트
"카탈로그를 문서로만 만들면 대개 죽습니다. 최신이 아니게 되는 순간 아무도 안 읽거든요. 그래서 저는 이야기를 테스트로 함께 돌게 배선합니다. 그러면 컴포넌트를 고칠 때 이야기도 같이 고쳐야 하니까, 문서가 최신인 상태가 강제로 유지됩니다. 문서를 살리는 건 의지가 아니라 배선이라고 생각합니다."
🤔 [생각해볼 주제 2] 안 쓰는 코드의 문제는 언제 고쳐야 할까요
문제 상황 요약
안 쓰는 버튼 변형에서 색 대비 문제를 발견했고 안 고치기로 했습니다. 표시만 남겼죠. 그런데 이 판단이 항상 옳을까요? 지금 고치자니 쓰지도 않는 것 때문에 색 체계를 건드리는 게 맞나 싶고, 안 고치자니 나중에 누가 그냥 쓸 것 같습니다.
튜터의 가이드 및 해설
이 질문의 핵심은 비용이 언제 가장 싼가입니다.
지금 고치면 색 체계를 건드려야 합니다. 그 변형의 배경은 같은 빨강을 아주 연하게 깐 것이라, 글자를 기준까지 어둡게 하려면 지금 쓰는 경고색과 또 다른 값이 하나 더 생겨요. 색이 늘어나면 다음 사람이 "언제 뭘 쓰지"를 또 고민합니다. 쓰지도 않는 것 때문에요.
나중에 고치면 어떨까요. 그 변형을 처음 쓰는 사람이 값을 치릅니다. 그런데 그 사람은 급할 가능성이 높아요. 삭제 확인 버튼 같은 걸 만들다가 쓰는 거니까, 색 체계를 다시 설계할 여유가 없습니다. 그래서 그냥 쓰고 넘어갑니다.
표시를 남기는 게 중간값입니다. 우리는 그 장면 위에 대비 숫자와 이유를 적어뒀어요. 다음 사람이 그 변형을 카탈로그에서 고르는 순간 그 글이 눈에 들어옵니다. 결정을 미룬 게 아니라 결정에 필요한 정보를 그 자리에 놓아둔 것이죠.
세 번째 선택지도 진지하게 볼 만합니다. 지우는 거예요. F-2에서 우리는 아무도 안 부르는 함수를 지웠고, 지우니까 딸린 것들이 함께 사라져 파일이 깨끗해졌습니다.
다만 이건 우리가 만든 코드가 아니라 들여온 것이라 다릅니다. 지워도 도구를 다시 돌리면 돌아오고, 우리가 판단할 근거도 약해요. 만든 사람들은 그 변형이 필요한 화면을 알고 있을 테니까요.
정리하면 판단 기준은 이렇습니다. 우리가 만든 것이면 지우는 쪽을 먼저 보고, 들여온 것이면 표시하는 쪽을 먼저 봅니다.
🎯 면접관을 홀리는 핵심 멘트
"안 쓰는 코드의 결함은 고치는 것보다 기록하는 게 나을 때가 많습니다. 고치면 지금 비용을 확실히 내는데 이득은 불확실하거든요. 대신 기록은 그 코드를 처음 쓰려는 사람 눈앞에 정확히 놓이게 둡니다. 저는 그걸 '결정을 미루는 것'이 아니라 '결정할 사람에게 정보를 넘기는 것'으로 봅니다."
🤔 [생각해볼 주제 3] 늘어놓는 것과 쓰는 것은 어디서 갈릴까요
문제 상황 요약
카탈로그를 세우니 버튼이 두 개라는 게 드러났습니다. 그런데 카탈로그가 보여주는 건 "무엇이 있는가" 이지 "무엇을 언제 써야 하는가"가 아니에요. 둘이 나란히 있어도 어느 것을 골라야 할지는 안 알려줍니다.
튜터의 가이드 및 해설
이건 카탈로그의 한계를 정확히 짚은 질문인데, 이 한계는 도구를 바꿔서 풀리는 게 아니에요.
먼저 카탈로그 안에 적을 수 있는 것과 없는 것을 갈라봅시다.
적을 수 있는 건 그 컴포넌트 하나에 대한 이야기입니다. 이 버튼은 어떤 값을 받고, 어떤 상태가 있고, 이럴 때 이렇게 보인다고요. 오늘 우리가 한 게 그겁니다.
적기 어려운 건 컴포넌트 사이의 관계예요. "이 둘 중에는 이걸 써라", "저건 옛날 것이니 새로 만들 때는 쓰지 마라" 같은 것들이죠. 카탈로그는 하나씩 보여주는 구조라 둘을 견주는 말을 놓을 자리가 마땅치 않습니다.
그래서 실무에서는 대개 이렇게 갑니다. 카탈로그 앞에 안내 문서를 한 장 두고 거기에 고르는 규칙을 적어요. 오늘 우리가 만든 도구에도 그런 문서를 쓰는 기능이 함께 왔습니다.
그런데 더 중요한 질문이 남습니다. 그 규칙이 지켜지는지는 무엇으로 확인하죠?
문서에 "옛 버튼은 새로 쓰지 마세요"라고 적어두는 것만으로는 안 지켜집니다. 읽는 사람만 지키니까요. 지켜지게 하려면 규칙을 도구로 옮겨야 합니다.
이 과목에서 우리가 여러 번 해본 방법이 있어요. 린트 규칙으로 그 파일을 가져오는 것 자체를 막을 수도 있고, 판을 하나 만들어 "옛 버튼을 쓰는 파일 수가 지금보다 늘면 빨개진다"로 잠글 수도 있습니다. 수치에 상한을 걸어 넘으면 빌드를 세우는 것도 같은 방식입니다.
그러니 답은 이렇게 정리됩니다. 카탈로그는 목록을 만들고, 문서는 규칙을 적고, 그 규칙을 지키는 건 판이나 린트가 합니다. 셋이 다른 일을 하고, 하나로 다 하려고 하면 어느 것도 제대로 안 됩니다.
🎯 면접관을 홀리는 핵심 멘트
"카탈로그는 '무엇이 있는가'까지만 답합니다. '무엇을 써야 하는가'는 문서의 몫이고, 그 문서가 지켜지는지는 린트나 테스트가 봐야 합니다. 문서에만 적어둔 규칙은 읽는 사람만 지키거든요. 저는 팀 규칙을 만들 때 항상 '이걸 어기면 무엇이 빨개지나'를 먼저 정합니다. 빨개지는 게 없으면 그건 규칙이 아니라 권고입니다."