발행일

Storybook 스터디 — 강의 정리와 정직한 결산

인프런 강의 "실무에 바로 적용하는 스토리북과 UI 테스트"를 완주하고 남긴 기록입니다. 강의 진행 순서를 그대로 옮기는 대신, 나중에 다시 꺼내 볼 만한 것만 주제별로 묶었습니다.

그리고 이 글에는 정리만 있지 않습니다. 마지막 "정직한 결산" 절에서, 이걸 다 배우고도 제 프로젝트 어디에도 Storybook을 넣지 않은 이유를 씁니다. 강의 후기 글이 대개 생략하는 부분이 그 대목이라고 생각합니다.

스토리북이 실제로 푸는 문제

강의는 디자인 시스템 이야기로 문을 엽니다. 스타일 가이드(색·타이포·간격), UI 컴포넌트, 패턴 라이브러리, 문서, 그리고 그걸 구현한 코드베이스. 여기까지는 어느 아티클에나 있는 내용이고, 실제로 중요한 건 그다음 한 문장입니다.

디자인 시스템의 진짜 비용은 만드는 게 아니라 "코드와 문서가 어긋나지 않게 유지하는 것"입니다.

Figma에는 최신 버튼이 있고 코드에는 작년 버튼이 있는 상태, 문서에는 없는 isLoading prop이 코드에는 있는 상태. 이 어긋남을 막는 게 Storybook의 존재 이유입니다. Storybook은 컴포넌트를 앱 전체에서 떼어 내 독립적으로 렌더링하고, 그 렌더링 자체가 문서가 되게 만듭니다. 문서를 따로 쓰는 게 아니라 문서가 실행되는 코드라서, 컴포넌트가 깨지면 문서도 같이 깨집니다. 그게 핵심입니다.

여기서 따라오는 부수 효과가 세 가지입니다.

  • 앱을 띄우고 로그인하고 5단계 들어가야 보이는 에러 상태를, 스토리 하나 클릭으로 봅니다.
  • 디자이너·QA가 같은 URL을 보면서 이야기합니다. "그 화면 그 상태"라는 말이 필요 없어집니다.
  • 접근성 검사, 시각적 회귀 테스트, 상호작용 테스트가 전부 이 스토리 위에 얹힙니다.

설치는 npx storybook init 한 줄이고, 프레임워크를 감지해서 .storybook/main.ts와 예제 스토리까지 만들어 줍니다.

스토리의 문법: meta 하나, story 여럿

Storybook 7 이후의 CSF3 문법에서 스토리 파일은 기본 export 하나(meta)와 이름 있는 export 여럿(story) 으로 구성됩니다.

// Label.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { Label } from './Label'

const meta: Meta<typeof Label> = {
  title: 'Components/Label', // 사이드바 경로
  component: Label,
  parameters: { layout: 'centered' },
  tags: ['autodocs'], // 문서 페이지 자동 생성
  argTypes: {
    text: { control: 'text', description: '표시될 텍스트' },
    color: { control: 'color', description: '텍스트 색상' },
  },
}
export default meta
type Story = StoryObj<typeof Label>

export const Default: Story = {
  args: { text: '기본 Label', color: '#000' },
}

export const Highlight: Story = {
  args: { ...Default.args, text: '강조된 Label', color: '#f00' },
}

meta의 각 필드가 하는 일은 이렇습니다.

필드역할
title사이드바 트리 경로. Components/Label이면 폴더 아래 항목으로 표시
component대상 컴포넌트. 타입 추론과 자동 문서의 근거
parameters렌더링 환경 설정. layout: 'centered'가 가장 자주 쓰임
tags['autodocs']를 넣으면 Docs 페이지가 자동 생성
argTypesprop별 컨트롤 타입과 설명. 문서의 props 표가 여기서 나옴

args를 퍼뜨려 쓰는 패턴(...Default.args)이 실무에서 제일 유용합니다. 기본 상태를 한 번 정의해 두고 변형만 덮어쓰면, 컴포넌트 props가 바뀌었을 때 고칠 자리가 한 군데로 모입니다.

이 문법을 익히면서 관점이 하나 바뀌었는데, 컴포넌트를 "props 조합이 만들어 내는 상태의 집합"으로 보게 된다는 점입니다. 스토리를 쓴다는 건 결국 "이 컴포넌트가 가질 수 있는 상태를 전부 나열하라"는 요구고, 나열하다 보면 설계가 이상한 컴포넌트는 나열이 안 됩니다. props가 12개인데 유효한 조합은 4개뿐인 컴포넌트를 만들었다면, 스토리를 쓰는 순간 그게 드러납니다.

Atomic하게 쌓아 올리기

강의는 TextField 하나를 만드는 과정을 Atomic Design 순서로 따라갑니다. 가장 작은 조각부터 만들고, 조합해서 올라갑니다.

1단계: ErrorMessage

가장 단순한 Atom입니다. 텍스트와 스타일만 있습니다.

interface ErrorMessageProps {
  message: string
  color?: string
  fontSize?: string | number
}

export const ErrorMessage: React.FC<ErrorMessageProps> = ({
  message,
  color = 'red',
  fontSize = 12,
}) => <p style={{ color, fontSize, margin: '5px 0' }}>{message}</p>

이 정도 컴포넌트에도 스토리를 쓰는 이유는 "에러 메시지가 두 줄이 되면 레이아웃이 어떻게 되는가", "긴 문장이면 잘리는가"를 앱을 띄우지 않고 확인하기 위해서입니다.

2단계: IconButton과 SVG 관리

interface IconButtonProps {
  icon: React.ReactNode
  onClick: () => void
  size?: number | string
  color?: string
}

iconReact.ReactNode로 받는 설계가 포인트입니다. 아이콘 이름을 문자열로 받아 내부에서 매핑하는 방식보다, 부모가 만든 엘리먼트를 그대로 받는 쪽이 타입 안전하고 트리 셰이킹에도 유리합니다.

SVG는 @svgr/webpack으로 React 컴포넌트화해서 assets/icons에 두고 import하는 방식을 씁니다.

export const EyeIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
  <svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
    <path d="M12 4.5c5 0 9 3.5 10.5 7.5-1.5 4-5.5 7.5-10.5 7.5S3 16 1.5 12C3 8 7 4.5 12 4.5Z" />
  </svg>
)

React.SVGProps<SVGSVGElement>를 그대로 받아 펼치면 width, height, fill, aria-hidden을 호출부에서 자유롭게 넘길 수 있습니다. 아이콘 컴포넌트를 만들 때 이 타입만 기억해 두면 됩니다.

3단계: 조합한 TextField

interface TextFieldProps {
  label?: string
  placeholder?: string
  value: string
  onChange: (value: string) => void
  errorMessage?: string
  type?: string
  icon?: React.ReactNode
  onIconClick?: () => void
}

export const TextField: React.FC<TextFieldProps> = ({
  label,
  placeholder,
  value,
  onChange,
  errorMessage,
  type = 'text',
  icon,
  onIconClick,
}) => (
  <div style={{ marginBottom: 20 }}>
    {label && <label style={{ display: 'block', marginBottom: 5 }}>{label}</label>}
    <div style={{ display: 'flex', alignItems: 'center', border: '1px solid #ccc', borderRadius: 4, padding: 8 }}>
      <input
        type={type}
        value={value}
        placeholder={placeholder}
        onChange={(e) => onChange(e.target.value)}
        style={{ flex: 1, border: 'none', outline: 'none' }}
      />
      {icon && <IconButton icon={icon} onClick={onIconClick ?? (() => {})} />}
    </div>
    {errorMessage && <ErrorMessage message={errorMessage} />}
  </div>
)

제어 컴포넌트로 만든 것이 이 설계의 핵심입니다. valueonChange를 밖에서 받기 때문에 폼 라이브러리와 붙일 수 있고, 스토리에서도 원하는 상태를 그대로 주입할 수 있습니다. 내부에 useState를 감춰 두면 "값이 있는데 에러도 떠 있는 상태"를 스토리로 만들 수 없습니다.

상태를 가진 스토리가 필요할 때는 render컴포넌트를 만들어서 넣어야 합니다. 훅은 컴포넌트 안에서만 호출할 수 있기 때문입니다.

const PasswordFieldDemo = () => {
  const [show, setShow] = useState(false)
  const [value, setValue] = useState('')
  return (
    <TextField
      label="비밀번호"
      value={value}
      onChange={setValue}
      type={show ? 'text' : 'password'}
      icon={show ? <EyeOffIcon /> : <EyeIcon />}
      onIconClick={() => setShow((prev) => !prev)}
      errorMessage={value.length < 8 ? '비밀번호는 8자 이상이어야 합니다' : ''}
    />
  )
}

export const PasswordField: Story = { render: () => <PasswordFieldDemo /> }

Decorator: 스토리에 맥락을 씌우기

컴포넌트가 테마 Provider나 라우터, 특정 배경 위에서만 제대로 보이는 경우가 있습니다. Decorator는 스토리를 감싸는 래퍼입니다.

// .storybook/preview.tsx
import type { Preview } from '@storybook/react'

const preview: Preview = {
  decorators: [
    (Story) => (
      <div style={{ padding: 20, backgroundColor: '#f9f9f9' }}>
        <Story />
      </div>
    ),
  ],
}
export default preview

특정 컴포넌트에만 씌우려면 meta에 decorators: [CenteredDecorator]를 넣습니다. 실무에서 가장 값어치가 큰 쓰임은 장식이 아니라 Provider 주입입니다. ThemeProvider, i18n, React Query Client를 글로벌 Decorator로 한 번 감싸 두면 모든 스토리가 앱과 같은 컨텍스트에서 렌더링됩니다.

CLS를 아는 컴포넌트 설계

강의에서 짧지만 오래 남은 대목이 Cumulative Layout Shift 이야기였습니다. 에러 메시지가 나타나면서 아래 콘텐츠를 밀어내는 것, 이미지가 로드되면서 레이아웃이 튀는 것이 전부 CLS입니다.

앞의 ErrorMessage도 정확히 이 문제를 갖고 있습니다. errorMessage && 조건부 렌더링이라 메시지가 생기는 순간 아래 요소가 밀립니다. 실무라면 에러 영역의 높이를 미리 확보하고 visibilityopacity로 전환하는 편이 낫습니다.

CLS가 Core Web Vitals를 거쳐 검색 순위에 영향을 준다는 점을 생각하면, 이건 취향 문제가 아니라 요구사항입니다. Storybook의 좋은 점은 이런 튐을 컴포넌트 단위에서 미리 볼 수 있다는 것입니다.

UI 테스트 네 가지

강의 후반부는 스토리 위에 얹는 테스트입니다. 네 가지가 서로 다른 것을 잡습니다.

테스트잡는 것도구
Visual (VRT)의도치 않은 픽셀 변화Chromatic
Accessibility대비·레이블·역할 위반@storybook/addon-a11y
Interaction클릭·입력 후의 상태 변화play 함수 + test-runner
CI 연동위 전부를 PR마다 자동 실행GitHub Actions + test-runner

Chromatic은 Storybook 팀이 만든 시각 회귀 서비스입니다. 강의에서 "Storybook 문서보다 Chromatic 문서를 보라"고 권하는데, 실제로 Chromatic 쪽 문서가 devDependency로 설치하라는 것까지 정확히 명시합니다. 별도 도메인이나 호스팅 설정 없이 HTTPS로 스토리북을 배포해 주는 것도 편합니다. PR을 만들면 변경된 스냅샷만 모아서 UI 리뷰를 걸어 줍니다.

a11y 애드온은 애드온만 켜면 각 스토리에서 위반 목록을 보여 주지만, CI에서 실패로 만들려면 @storybook/test-runner가 필요합니다. 애드온만 켜 놓고 CI가 잡아 줄 거라 기대하면 아무것도 안 잡힙니다.

Interaction 테스트는 스토리의 play 함수 안에서 사용자 동작을 재현합니다. E2E와 비슷하지만 브라우저 전체가 아니라 Storybook canvas 안에서 컴포넌트 단위로 돕니다. 그래서 빠르고, 대신 라우팅이나 API 통합처럼 앱 전체를 지나는 문제는 못 잡습니다. 앱 전체를 훑는 검증은 결국 Playwright로 메뉴 136종을 쓸어 보는 식의 별도 레이어가 필요합니다.

강의 자료를 다시 읽으며 발견한 것들

정리하면서 원래 필기를 검토했더니, 그대로 두면 안 되는 대목이 몇 개 있었습니다. 위 본문은 이미 고쳐 쓴 버전이고, 무엇을 고쳤는지 남겨 둡니다.

1. addDecorator는 이제 없습니다. 옛 필기에는 preview.ts에서 addDecorator(CenteredDecorator)를 호출하는 코드가 있었는데, 이 API는 Storybook 7에서 제거됐습니다. 같은 문서가 다른 곳에서는 Meta/StoryObj라는 CSF3 문법을 쓰고 있었으니, 앞뒤가 맞지 않는 상태였습니다. 지금은 preview.tsx에서 decorators 배열을 export합니다.

2. Knobs 애드온도 없습니다. 애드온 목록에 Knobs가 들어 있었는데, Knobs는 Controls로 대체되어 사라졌습니다. 본문에서 계속 쓰던 argTypes + control이 바로 그 후신입니다. 스스로 Controls를 쓰면서 Knobs를 소개하고 있었던 셈입니다.

3. Template.bind({})는 CSF2 문법입니다. 초반 예제만 옛 문법이고 이후 전부 CSF3였습니다. 지금은 굳이 섞을 이유가 없어 CSF3로 통일했습니다.

4. render에서 훅을 부르면 안 됩니다. PasswordField 스토리가 render: () => { const [x, setX] = useState() ... } 형태였는데, render는 컴포넌트가 아니라 일반 함수라 훅 규칙 위반입니다. 위에 쓴 것처럼 별도 컴포넌트로 감싸야 합니다.

5. 동적 SVG import는 그대로는 안 돕니다. 아이콘 이름을 템플릿 문자열로 만들어 React.lazy 안에서 .svg를 동적 import하는 코드가 있었는데, SVGR 설정과 번들러의 정적 분석 조건이 맞아야만 동작합니다. 아이콘 개수가 확정적인 경우엔 그냥 정적 import가 낫습니다.

6. 스토리 title이 중복돼 있었습니다. Components/TextField가 두 번, Components/TagList가 두 번 등장했습니다. Storybook은 스토리 ID를 title 기준으로 만들기 때문에 중복은 실제로 충돌을 냅니다.

7. "Event Bubbling을 활용한 TagList"라는 제목의 코드가 사실은 버블링을 막고 있었습니다. 태그 클릭 핸들러와 삭제 핸들러 양쪽에서 stopPropagation()을 부르는데, 이건 버블링을 활용하는 게 아니라 차단하는 것입니다. 삭제 아이콘 쪽에서 부모 클릭으로 전파되는 걸 막는 건 타당하지만, 최상위 클릭 핸들러에서 부르는 건 의미가 없습니다. 제목과 코드가 정반대였습니다.

정직한 결산: 배웠지만 도입하지 않았다

여기까지 정리해 놓고 말하기 민망하지만, 저는 이 강의를 완주한 뒤 제 어떤 프로젝트에도 Storybook을 도입하지 않았습니다. 그러기로 결정한 이유를 적는 게 이 글에서 제일 쓸모 있는 부분이라고 생각합니다.

첫째, 2인 사이드 프로젝트에서는 카탈로그 유지 비용이 효용을 넘습니다. 2년 묵은 사이드 프로젝트를 되살리는 작업을 하면서 이 판단을 굳혔습니다. Storybook의 가치는 "같은 컴포넌트를 여러 사람이, 앱을 띄우지 않고, 각자의 맥락에서 확인해야 할 때" 나옵니다. 전달 대상이 사실상 없는 팀에서는 컴포넌트 옆에 스토리 파일을 하나 더 두는 일이 순수한 추가 부채입니다. props가 바뀔 때마다 컴포넌트와 스토리를 같이 고쳐야 하고, 안 고치면 스토리가 썩고, 썩은 스토리는 없는 것보다 나쁩니다. 실제로 유지되지 않을 카탈로그를 만드는 건 문서를 만드는 게 아니라 거짓말을 만드는 것입니다.

둘째, 서버 렌더링 프로젝트에는 전제 자체가 맞지 않습니다. 실무에서 다루는 SDMS는 Django + Jinja 템플릿 기반입니다. 여기서 "컴포넌트"의 단위는 JavaScript 함수가 아니라 서버에서 렌더링되는 템플릿 조각이고, 상태는 props가 아니라 뷰가 넘긴 컨텍스트에서 옵니다. Storybook은 "컴포넌트가 JS 함수이고 props로 상태를 주입할 수 있다"는 전제 위에 서 있는 도구라, 이 구조에서는 도구를 쓰려고 아키텍처를 비트는 결과가 됩니다. 도구가 좋아서 쓰는 것과, 도구를 쓰려고 문제를 개조하는 것은 다른 일입니다.

그래서 아무것도 안 남았느냐 하면, 그렇지 않습니다.

컴포넌트를 "props 조합이 만드는 상태 집합"으로 보는 관점은 Storybook 없이도 남았습니다. 새 컴포넌트를 설계할 때 머릿속으로 스토리 목록을 먼저 세어 봅니다. 기본, 비활성, 로딩, 에러, 긴 텍스트, 빈 값. 여섯 개가 셈이 안 되면 props 설계가 잘못된 겁니다. 이 습관은 파일 하나 안 늘리고도 씁니다.

제어 컴포넌트로 설계하는 습관도 남았습니다. 상태를 안에 감추면 스토리를 못 쓴다는 걸 배우고 나니, 스토리를 안 쓰더라도 상태를 밖으로 빼게 됐습니다. 테스트하기 쉬운 설계와 스토리 쓰기 쉬운 설계는 같은 것이었습니다.

CLS를 컴포넌트 단위에서 생각하는 것도 남았습니다. 에러 메시지 하나 띄우면서 아래를 밀어내는지 아닌지는, Storybook이 없어도 만들면서 확인할 수 있습니다.

결국 강의값 이상은 했다고 생각합니다. 다만 그 값어치가 "Storybook을 쓰게 됐다"가 아니라 "컴포넌트를 보는 눈이 바뀌었고, 언제 이 도구가 필요한지 판단할 수 있게 됐다"에 있었을 뿐입니다. 팀이 커지고 디자이너와 QA가 같은 컴포넌트를 봐야 하는 순간이 오면, 그때는 주저 없이 깔 겁니다. 그 판단 기준을 갖게 된 게 수료증보다 값진 결과였습니다.

수료증