| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | ||
| 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 13 | 14 | 15 | 16 | 17 | 18 | 19 |
| 20 | 21 | 22 | 23 | 24 | 25 | 26 |
| 27 | 28 | 29 | 30 |
- frontenddevelopment
- frontend
- 리액트
- 웹개발
- refresh token
- Next
- react성능최적화
- NextJs
- 성능최적화
- JavaScript
- 최적화
- lighthouse
- JWT
- fe
- 이미지최적화
- 프론트엔드개발
- RTR
- 리팩토링
- Refactoring
- 프론트엔드성능
- 프론트엔드
- TanStackQuery
- react
- 데이터캐싱
- 비동기처리
- TechBlog
- WebP
- reactquery
- useEffect
- 프론트엔드개발자
- Today
- Total
juncci 님의 블로그
[Storybook] 도입과 운영 기록 본문
프로젝트가 커질수록 가장 먼저 흔들리는 건 화면 자체보다도 그 화면을 이루는 작은 UI 조각들이다. 버튼 하나의 스타일이 미묘하게 달라지고, 모달 하나의 동작이 페이지마다 어긋나고, 어떤 상태에서는 잘 보이던 컴포넌트가 다른 상태에서는 깨지기도 한다. 특히 공용 UI 컴포넌트를 여러 페이지와 기능에서 재사용하는 구조라면 이런 문제는 더 빠르게 누적된다.
우리 프로젝트도 비슷한 문제를 안고 있었다. shared/ui 아래에 버튼, 모달, 바텀시트, 입력 컴포넌트 같은 공용 UI가 모여 있었고 이 컴포넌트들은 여러 화면에서 반복적으로 사용됐다.
특정 페이지 하나만 잘 동작한다고 해서 안심할 수 있는 구조가 아니었다.
이 문제를 해결하기 위해 Storybook을 도입했다.
Storybook

Storybook은 UI 컴포넌트를 실제 애플리케이션과 분리된 환경에서 개발하고, 문서화하고, 테스트할 수 있게 해주는 도구다.
쉽게 말하면 “페이지 안에서 우연히 확인하는 UI”가 아니라, “컴포넌트를 독립적으로 꺼내놓고 정확하게 검증하는 방식”이라고 볼 수 있다.
보통 프론트엔드 개발에서는 버튼 하나를 수정해도 그 버튼이 들어간 페이지까지 직접 들어가서 확인해야 한다.
하지만 Storybook을 쓰면 버튼 자체를 독립적으로 렌더링해서, primary 상태인지 secondary 상태인지, disabled 상태인지, 긴 텍스트가 들어왔을 때도 괜찮은지 같은 경우를 각각 따로 볼 수 있다. 이게 중요한 이유는 컴포넌트의 역할과 상태를 명확하게 분리해 관리할 수 있기 때문이다.
Storybook의 핵심 가치는 크게 세 가지다.

첫째, 컴포넌트를 페이지와 분리해서 개발할 수 있다.
둘째, 상태와 변형을 문서처럼 남길 수 있다.
셋째, 클릭, 닫기, 포커스 이동 같은 상호작용까지 자동으로 검증할 수 있다.
Storybook을 도입한 이유
우리 프로젝트는 FSD 구조를 기반으로 하고 있었고 shared/ui는 이름 그대로 여러 기능과 위젯에서 함께 쓰는 공용 UI 레이어였다.
2026.02.08 - [[FE]] - FSD 아키텍처 적용기: 코드 위치를 설계하다
FSD 아키텍처 적용기: 코드 위치를 설계하다
FSD(Feature-Sliced Design) 아키텍처를 알고 계신가요? 프론트엔드 프로젝트를 몇 번 반복하다 보면 일정 시점부터 비슷한 문제를 만나시지 않으셨나요? 초기에는 빠르게 개발되던 코드가 기능이 늘어
juncci.tistory.com
여기 있는 버튼, 모달, 바텀시트 같은 컴포넌트는 한 곳에서만 쓰이는 것이 아니라 프로젝트 전반에 걸쳐 재사용된다.
이 말은 곧, 한 번의 수정이 여러 화면에 영향을 줄 수 있다는 뜻이기도 하다.
처음에는 페이지 단위로 기능을 확인하는 방식으로도 어느 정도 대응할 수 있었다. 하지만 프로젝트가 커질수록 한계가 명확해졌다. 어떤 컴포넌트가 어떤 상태를 지원하는지 한눈에 파악하기 어려웠다. 또한 버튼이나 모달처럼 작은 UI 단위의 회귀가 페이지 테스트만으로는 잘 잡히지 않았다.
그래서 우리는 Storybook을 도입하면서 목표를 분명히 잡았다.
공용 UI를 독립적으로 검증할 것.
컴포넌트의 상태와 변형을 문서화할 것.
상호작용 테스트를 CI에 연결해 자동 검증할 것.
특히 세 가지 가치 중에서도 컴포넌트의 상태와 변형을 문서화하는 것에 가장 집중했다. 공용 UI를 단순히 재사용하는 수준을 넘어서 어떤 상태를 지원하고 어떤 방식으로 동작하는지를 코드로 명시된 문서 형태로 남기는 것이 중요하다고 판단했기 때문이다.
Storybook 구성
프로젝트에서는 Next.js 기반 환경에 Storybook을 구성했다.

이때 사용한 @storybook/nextjs-vite는 Next.js 환경에 맞게 Storybook을 실행할 수 있도록 해주는 프레임워크로 기존 앱 구조와 큰 차이 없이 컴포넌트를 렌더링할 수 있게 해준다.
또한 몇 가지 핵심 addon을 함께 적용했다.
Storybook에서 addon은 기능을 확장하는 플러그인 개념인데 두 가지를 중심으로 구성했다.
- @storybook/addon-docs: 컴포넌트의 props, 사용 예시 등을 자동으로 문서화해주는 기능
- @storybook/addon-a11y: 접근성(a11y) 기준을 검사해주는 도구
addon-docs를 통해 스토리를 단순한 예제가 아니라 문서처럼 활용할 수 있게 했고,
addon-a11y를 통해 UI가 접근성 기준을 만족하는지도 함께 검증할 수 있도록 했다.
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/nextjs-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: ['@storybook/addon-a11y', '@storybook/addon-docs'],
framework: '@storybook/nextjs-vite',
staticDirs: ['../public'],
};
export default config;
포인트
- Next.js + Vite 조합(@storybook/nextjs-vite)
- 접근성 검사(addon-a11y)
- 문서 자동화(addon-docs)
- public 정적 자산 그대로 사용
전역 파라미터(autodocs + a11y 에러화)
//preview.ts
import type { Preview } from '@storybook/nextjs-vite';
import '../src/app/globals.css';
const preview: Preview = {
tags: ['autodocs'],
parameters: {
a11y: { test: 'error' },
layout: 'centered',
backgrounds: {
default: 'app-bg',
values: [
{ name: 'app-bg', value: '#000000' },
{ name: 'surface', value: '#f7f7f7' },
],
},
},
};
export default preview;
포인트
- tags: ['autodocs']로 문서 자동 생성
- a11y.test = 'error'로 접근성 위반을 실패 조건으로 관리
- 배경 프리셋으로 실제 서비스 톤에 맞춰 확인
접근성(a11y) 설정은 조금 더 강하게 가져갔다.
단순히 경고를 보여주는 수준이 아니라, 접근성 위반이 발생하면 에러로 간주하도록 설정했다.
문제가 있으면 참고사항으로 넘기는 것이 아니라, 반드시 수정해야 하는 실패 조건으로 관리할 수 있게 하였다.
정적 자산은 별도의 설정 없이 기존 Next.js 프로젝트에서 사용하던 public 디렉터리를 그대로 연결해 사용했다.
덕분에 이미지나 아이콘 같은 리소스를 Storybook에서도 동일하게 재사용할 수 있었다.
preview.ts에서는 Storybook 전역 설정을 정의했다.
먼저 autodocs를 활성화해 각 스토리를 기반으로 컴포넌트 문서를 자동으로 생성하도록 했다.
덕분에 별도의 문서를 작성하지 않아도 스토리 자체가 곧 문서 역할을 하게 만들 수 있었다.
또한 배경(background) 프리셋을 서비스의 실제 색감에 맞게 지정해, Storybook에서도 최대한 실제 화면과 유사한 환경에서 컴포넌트를 확인할 수 있도록 했다. 이는 단순히 UI를 보는 것을 넘어, 실제 사용 환경에서의 시각적 일관성까지 검증하기 위한 설정이었다.
Storybook에서 관리한 Shared UI 컴포넌트
이번 프로젝트에서는 SHARED 그룹 기준으로 아래 공통 UI 컴포넌트들을 Storybook에 등록해 관리했다.

각 컴포넌트는 단순 렌더링 확인 수준이 아니라, 실제 서비스에서 사용하는 상태를 기준으로 스토리를 분리해 문서화했다.

예를 들어 Button은 variant/disabled/텍스트 길이,
Modal과 BottomSheet는 열림/닫힘 및 사용자 상호작용,
Input은 기본/에러/비활성 상태를 중심으로 구성했다.
실전 패턴
상태를 스토리로 분해해 UI 계약을 고정
Button.stories.tsx
export const Primary: Story = {};
export const Secondary: Story = { args: { variant: 'secondary', children: '보조 버튼' } };
export const Ghost: Story = { args: { variant: 'ghost', children: '취소' } };
export const Disabled: Story = { args: { disabled: true, children: '비활성화' } };
export const LongText: Story = { args: { children: '지원서 초안을 저장하고 다음 단계로 이동하기' } };
- “버튼은 primary/secondary/ghost를 가진다”를 말이 아니라 코드로 문서화
- 긴 텍스트/비활성 등 경계 케이스를 시각 회귀 포인트로 고정
argTypes로 Playground 제공
Button.stories.tsx
argTypes: {
variant: { control: 'select', options: ['primary', 'secondary', 'ghost'] },
disabled: { control: 'boolean' },
children: { control: 'text' },
}
- 디자이너/기획/개발이 같은 UI를 즉시 파라미터 변경하며 확인
- “디자인 토큰 적용 체감”이 빨라짐
play 테스트로 인터랙션을 스토리 안에서 자동 검증
버튼 클릭 예시(Button.stories.tsx)
export const ClickInteraction: Story = {
args: { children: '클릭 테스트', onClick: fn() },
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button', { name: '클릭 테스트' });
await userEvent.click(button);
await expect(args.onClick).toHaveBeenCalledTimes(1);
},
};
모달 열고 닫기 예시(Modal.stories.tsx):
await userEvent.click(canvas.getByRole('button', { name: '모달 열기' }));
await expect(await body.findByRole('dialog')).toBeInTheDocument();
await userEvent.click(body.getByRole('button', { name: '취소' }));
await waitFor(() => {
expect(body.queryByRole('dialog')).not.toBeInTheDocument();
});
바텀시트 ESC 닫힘 예시(BottomSheet.stories.tsx):
await userEvent.keyboard('{Escape}');
await waitFor(() => {
expect(body.queryByRole('dialog')).not.toBeInTheDocument();
});
접근성 예외를 명시적으로 관리
BlurText는 초기 프레임이 의도적으로 흐림/저대비이므로, 스토리 단에서 a11y를 비활성화했다.
//파일: BlurText.stories.tsx
parameters: {
a11y: { disable: true },
docs: {
description: {
component:
'초기 프레임에서 의도적으로 낮은 opacity를 사용하므로 a11y 대비 검증은 스토리에서 제외합니다.',
},
},
}
Storybook을 운영 도구로 확장한 방법
Storybook을 잘 도입하는 것과, 잘 운영하는 것은 다른 문제다. 우리는 여기서 한 단계 더 나아가 스토리 품질 자체를 감사하는 스크립트까지 추가했다.
scripts/storybook-audit.mjs는 src/shared/ui를 순회하면서, 컴포넌트는 있는데 스토리가 없는 슬라이스가 있는지, 스토리는 있는데 play 테스트가 전혀 없는 슬라이스가 있는지를 검사한다. 그리고 문제가 있으면 CI에서 실패하도록 구성했다.
// scripts/storybook-audit.mjs
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
const ROOT = process.cwd();
const SHARED_UI_DIR = path.join(ROOT, 'src', 'shared', 'ui');
async function readDirSafe(target) {
try {
return await readdir(target, { withFileTypes: true });
} catch {
return [];
}
}
async function collectStoryMetrics(sliceDir) {
const entries = await readDirSafe(sliceDir);
const componentFiles = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((name) => name.endsWith('.tsx'))
.filter((name) => !name.includes('.stories.'))
.filter((name) => name !== 'index.tsx');
const storyFiles = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((name) => /\\.stories\\.(ts|tsx|js|jsx|mdx)$/.test(name));
const storySummaries = [];
for (const storyFile of storyFiles) {
const storyPath = path.join(sliceDir, storyFile);
const source = await readFile(storyPath, 'utf8');
const storyExports = (source.match(/export\\s+const\\s+/g) ?? []).length;
const playBlocks = (source.match(/\\bplay\\s*:/g) ?? []).length;
storySummaries.push({
storyFile,
storyExports,
playBlocks,
});
}
return {
slice: path.basename(sliceDir),
componentFiles,
storyFiles,
storySummaries,
};
}
async function main() {
const sliceEntries = await readDirSafe(SHARED_UI_DIR);
const sliceDirs = [];
for (const entry of sliceEntries) {
if (!entry.isDirectory()) continue;
const fullPath = path.join(SHARED_UI_DIR, entry.name);
const info = await stat(fullPath);
if (info.isDirectory()) sliceDirs.push(fullPath);
}
const metrics = [];
for (const sliceDir of sliceDirs) {
metrics.push(await collectStoryMetrics(sliceDir));
}
const missingStorySlices = metrics
.filter((m) => m.componentFiles.length > 0 && m.storyFiles.length === 0)
.map((m) => m.slice);
const slicesWithoutPlay = metrics
.filter((m) => m.storyFiles.length > 0)
.filter((m) => m.storySummaries.every((item) => item.playBlocks === 0))
.map((m) => m.slice);
console.log('Storybook Audit (shared/ui)');
if (missingStorySlices.length > 0) {
console.log(`- missing stories: ${missingStorySlices.join(', ')}`);
}
if (slicesWithoutPlay.length > 0) {
console.log(`- no play coverage: ${slicesWithoutPlay.join(', ')}`);
}
const failed = missingStorySlices.length > 0 || slicesWithoutPlay.length > 0;
if (failed) {
process.exitCode = 1; // CI 실패
console.log('Result: FAILED');
return;
}
console.log('Result: PASSED');
}
await main();
// package.json (연결된 실행 스크립트)
// {
// "scripts": {
// "storybook:audit": "node scripts/storybook-audit.mjs",
// "storybook:ci": "pnpm storybook:audit && pnpm build-storybook && pnpm test-storybook:ci"
// }
// }
이게 왜 좋았냐면, Storybook이 “누군가 열심히 관리해야 유지되는 문서”가 아니라, 관리하지 않으면 실패하는 체계가 되었기 때문이다. 덕분에 공용 UI를 새로 추가하면서 스토리를 빠뜨리거나 동작 검증 없는 문서성 스토리만 남기는 상황을 줄일 수 있었다.
패키지 스크립트도 그 흐름에 맞춰 정리했다. 로컬 개발용 Storybook 실행, 정적 빌드, test runner 실행, 감사 스크립트 실행을 각각 분리하고 CI에서는 이를 한 줄로 이어 실행하도록 구성했다. 결과적으로 storybook:ci 하나로 감사, 빌드, 상호작용 테스트가 모두 돌아가게 만들었다.
이런 구성은 팀 운영 관점에서 상당히 실용적이다. Storybook을 “한 번 세팅하고 잊어버리는 개발 편의 기능”이 아니라, 품질 보증 흐름 안에 포함된 검증 단계로 만들 수 있기 때문이다.
마무리
가장 큰 효과는 공용 UI의 상태가 명확해졌다는 점이다. 이전에는 버튼이 정확히 어떤 variant를 지원하는지, 모달이 어떤 닫힘 동작을 보장하는지, 바텀시트가 키보드 상호작용을 지원하는지 등을 코드나 실제 페이지를 뒤져야 했다. 이제는 Storybook에 들어가면 상태와 동작이 모두 드러난다.
두 번째는 회귀를 빨리 잡을 수 있게 되었다는 점이다. 특히 상호작용 테스트가 들어가면서 “렌더링은 되지만 실제 동작은 깨진 상태”를 조기에 확인할 수 있었다. UI는 보기만 해서는 충분하지 않고, 클릭과 열림, 닫힘, 이벤트 처리까지 함께 검증돼야 한다. 우리는 그 지점을 Storybook 안으로 끌어왔다.
세 번째는 협업 비용이 줄었다는 점이다. 디자이너, 기획자, 개발자 모두가 공용 UI의 상태를 같은 기준으로 볼 수 있게 되었다.
그리고 무엇보다 중요한 건, Storybook이 실제 서비스 코드와 동떨어진 별도 샘플 모음이 아니라는 점이다.
프로젝트의 레이아웃, 토스트 공급자, 인증 게이트, 실제 위젯 화면 등에서 사용하는 공용 UI 컴포넌트와 Storybook에서 검증하는 대상이 동일한 자산이기 때문에, 문서와 실사용 사이의 간극이 작다.
'[FE]' 카테고리의 다른 글
| [A/B 테스트] “감”이 아니라 “증명”으로 만드는 방법 (0) | 2026.02.24 |
|---|---|
| [디자인 시스템] TypeScript + Tailwind CSS 4.0 환경에서 구축하기 (0) | 2026.02.14 |
| FSD 아키텍처 적용기: 코드 위치를 설계하다 (0) | 2026.02.08 |
| Next.js App Router에서 BFF 레이어를 구성한 방법과 고민 (0) | 2026.01.25 |
| 🐣자바스크립트에서 덕 타이핑(Duck Typing) 이해하기 (0) | 2025.09.16 |