목적: ui 컴포넌트를 UX팀과 커뮤니케이션하기 위한 Storybook 환경 구축
스택: pnpm + Turborepo + React + TypeScript + Vite
ui가 이미 Vite로 구성되어 있으므로, Storybook은 @storybook/react-vite 프레임워크를 사용하여 기존 vite.config.ts를 그대로 활용한다.
cd ui
pnpm dlx storybook@latest init --type react
init이 자동으로 Vite 프로젝트를 감지하여 @storybook/react-vite를 설치한다.
만약 감지하지 못하면 수동 설치:
pnpm add -D storybook @storybook/react-vite @storybook/addon-essentials @storybook/addon-a11y
자동 생성되는 것들:
.storybook/main.ts — Storybook 설정.storybook/preview.ts — 글로벌 데코레이터, 파라미터src/stories/ — 예시 스토리 (삭제해도 됨)import type { StorybookConfig } from "@storybook/react-vite";
import { mergeConfig } from "vite";
const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(ts|tsx)"],
addons: [
"@storybook/addon-essentials",
"@storybook/addon-a11y",
],
framework: {
name: "@storybook/react-vite",
options: {},
},
docs: {
autodocs: "tag",
},
// 핵심: 기존 vite.config.ts를 Storybook이 자동으로 로드한다.
// 추가 커스텀이 필요한 경우에만 viteFinal을 사용.
viteFinal: async (config) => {
return mergeConfig(config, {
// 예: 모노레포 심볼링크 resolve 이슈가 있을 때
// resolve: {
// dedupe: ["react", "react-dom"],
// },
});
},
};
export default config;
Storybook + Vite 동작 원리
@storybook/react-vite는 ui의vite.config.ts를 자동 로드한다.
즉, 기존에 설정한 path alias, plugins, resolve 등이 Storybook에도 그대로 적용된다.
viteFinal은 Storybook 전용으로 추가 설정이 필요할 때만 쓴다 (덮어쓰기가 아닌 merge).
ui/
├── vite.config.ts ← 기존 Vite 설정 (라이브러리 빌드용)
├── .storybook/
│ ├── main.ts ← Storybook이 vite.config.ts를 자동 로드 + viteFinal로 확장
│ └── preview.ts ← 글로벌 데코레이터
주의할 점:
| 항목 | 설명 |
|---|---|
| path alias | vite.config.ts에 resolve.alias 설정이 있으면 Storybook에도 자동 적용됨 |
| plugins | vite.config.ts의 @vitejs/plugin-react 등도 자동 적용됨 |
| env 변수 | .env 파일도 Vite 규칙대로 자동 로드됨 |
| 빌드 설정 | build.lib 같은 라이브러리 빌드 설정은 Storybook 빌드 시 무시됨 (Storybook이 자체 빌드 파이프라인 사용) |
만약 vite.config.ts에 라이브러리 빌드(build.lib)와 Storybook이 충돌하는 경우:
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": "/src",
},
},
// build.lib 설정은 Storybook이 자동으로 무시하므로 충돌 없음
build: {
lib: {
entry: "src/index.ts",
formats: ["es", "cjs"],
},
rollupOptions: {
external: ["react", "react-dom"],
},
},
});
import type { Preview } from "@storybook/react";
// ui의 글로벌 스타일이 있다면 여기서 import
// import "../src/styles/global.css";
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
options: {
storySort: {
order: ["Foundation", "Components", "Patterns"],
},
},
},
};
export default preview;
ui/
├── vite.config.ts
├── .storybook/
│ ├── main.ts
│ └── preview.ts
├── src/
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.stories.tsx ← 컴포넌트 옆에 배치
│ │ └── index.ts
│ ├── Input/
│ │ ├── Input.tsx
│ │ ├── Input.stories.tsx
│ │ └── index.ts
│ └── index.ts ← 라이브러리 엔트리포인트
// src/Button/Button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";
const meta = {
title: "Components/Button",
component: Button,
tags: ["autodocs"],
argTypes: {
variant: {
control: "select",
options: ["primary", "secondary", "ghost"],
description: "버튼 스타일 변형",
},
size: {
control: "select",
options: ["sm", "md", "lg"],
description: "버튼 크기",
},
disabled: {
control: "boolean",
description: "비활성화 상태",
},
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
variant: "primary",
children: "확인",
},
};
export const Secondary: Story = {
args: {
variant: "secondary",
children: "취소",
},
};
export const AllVariants: Story = {
render: () => (
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="primary" disabled>Disabled</Button>
</div>
),
};
{
"name": "@myorg/ui",
"scripts": {
"dev": "vite", // 기존
"build": "vite build", // 기존
"storybook": "storybook dev -p 6006", // 추가
"build-storybook": "storybook build -o storybook-static" // 추가
},
"devDependencies": {
"vite": "^6.x", // 기존
"@vitejs/plugin-react": "^4.x", // 기존
"storybook": "^8.x", // 추가
"@storybook/react-vite": "^8.x", // 추가
"@storybook/addon-essentials": "^8.x", // 추가
"@storybook/addon-a11y": "^8.x" // 추가
}
}
# ui/.gitignore
storybook-static/
| 증상 | 원인 | 해결 |
|---|---|---|
Cannot find module 'react' | 모노레포에서 react가 hoist 안 됨 | viteFinal에서 resolve.dedupe: ["react", "react-dom"] 추가 |
path alias (@/) 안 먹힘 | vite.config.ts 로드 실패 | storybook dev 로그에서 Using Vite config 확인 |
| CSS Modules 스타일 안 나옴 | Vite 기본 지원이므로 별도 설정 불필요 | .module.css 확장자 확인 |
| HMR 안 됨 | Storybook Vite 포트 충돌 | storybook dev -p 6007로 포트 변경 |
build.lib 관련 에러 | Storybook 빌드와 충돌 (드문 케이스) | viteFinal에서 build.lib 명시적 제거: { build: { lib: undefined } } |
| 옵션 | 장점 | 단점 |
|---|---|---|
| Amplify | Git push만으로 자동 배포, PR별 프리뷰, 무료 티어 | AWS 계정 필요 |
| Vercel | 빠른 설정 | 무료 티어 제한 |
| Chromatic | Storybook 특화 (비주얼 테스트) | 유료 |
| GitHub Pages | 무료 | 수동 설정 필요, PR 프리뷰 없음 |
Amplify는 정적 사이트 배포에 무료 티어가 넉넉하고, PR별 프리뷰 URL을 자동 생성해서 UX팀에게 링크 공유하기 좋다.
uiversion: 1
applications:
- appRoot: ui
frontend:
phases:
preBuild:
commands:
- cd ../../
- npm install -g pnpm
- pnpm install --frozen-lockfile
build:
commands:
- cd ../../
- pnpm --filter @myorg/ui build-storybook
artifacts:
baseDirectory: storybook-static
files:
- "**/*"
cache:
paths:
- ../../node_modules/**/*
- node_modules/**/*
주의:
appRoot을ui로 설정하면 Amplify가 해당 디렉토리에서 빌드를 시작한다.
pnpm install은 모노레포 루트에서 해야 하므로cd ../../로 이동이 필요하다.
PNPM_VERSION = 9
NODE_OPTIONS = --max-old-space-size=4096
Amplify 콘솔 → App settings → Previews → Enable previews
main push → 메인 Storybook URL 자동 배포Amplify 콘솔 → Access control → Manage access
// turbo.json (루트)
{
"tasks": {
"build-storybook": {
"dependsOn": ["^build"],
"outputs": ["storybook-static/**"]
},
"storybook": {
"dependsOn": ["^build"],
"cache": false,
"persistent": true
}
}
}
// package.json (루트)
{
"scripts": {
"storybook": "turbo run storybook --filter=@myorg/ui",
"build-storybook": "turbo run build-storybook --filter=@myorg/ui"
}
}
루트에서:
pnpm storybook # 로컬 개발 서버
pnpm build-storybook # 정적 빌드 (배포용)
Storybook 관련 의존성은 ui의 devDependencies에만 둔다.
루트/
├── package.json ← storybook 의존성 없음 (스크립트만)
├── turbo.json ← 파이프라인 정의
├── pnpm-workspace.yaml
├── amplify.yml ← 배포 설정
└── ui/
├── vite.config.ts ← 기존 Vite 설정 (Storybook이 자동 로드)
├── package.json ← storybook devDependencies 여기
├── .storybook/ ← storybook 설정 여기
└── src/
└── Button/
├── Button.tsx
└── Button.stories.tsx
루트에 storybook 의존성을 올리면 다른 패키지까지 오염될 수 있다.
turbo의--filter가 해당 패키지의 의존성만 설치/빌드하게 해주므로
관심사 분리가 유지된다.
# .github/workflows/storybook.yml
name: Storybook
on:
push:
branches: [main]
paths:
- "ui/**"
pull_request:
paths:
- "ui/**"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm build-storybook
Amplify가 Git 연동 배포를 하므로 CI에서는 빌드 성공 여부만 검증.
개발자가 컴포넌트 수정
→ PR 생성
→ Amplify가 프리뷰 URL 자동 생성
→ UX팀에게 프리뷰 URL 공유
→ Storybook에서 컴포넌트 확인 + Controls로 직접 조작
→ 피드백 → PR에 코멘트
→ 머지 → 메인 Storybook URL 자동 업데이트