공통 컴포넌트가 많은 프로젝트는 아니긴 하지만...
Storybook을 꼭 써보고 싶어서 이번 프로젝트에 연동해봤다.
Storybook 설치
pnpm dlx storybook@latest init
우선 pnpm을 사용하고 있어서, 이렇게 설치했다.
@latest는 최신 메이저 버전을 설치하는 것이다.
👋 New to Storybook?
그러면 이렇게 물어보는데, 나는 처음 설치해보는 거라 Yes라고 했다.
컴포넌트 작성 예시랑 애드온 소개 등을 준다고 한다.
pnpm storybook
이렇게 하면 실행되는데, 오류가 났다.
SB_CORE-SERVER_0002 (CriticalPresetLoadError): Storybook failed to load the following preset: @storybook\nextjs-vite\preset.
흠.. 뭐라는겨.
GPT한테 물어보니 Storybook 9.x가 @storybook/nextjs-vite preset을 로드하려다가 Vite 쪽에서 터진 오류라고 한다.
Storybook 9.x에서는 Next.js 프로젝트에 webpack 기반 preset(@storybook/nextjs)과
vite 기반 preset(@storybook/nextjs-vite)이 둘 다 존재하는데, Vite preset이 로드될 때 __dirname이 중복 선언되어 오류가 발생했다고 한다.
import type { StorybookConfig } from '@storybook/nextjs';
그래서 Vite preset이 아닌 Webpack preset을 사용해주면 해결된다.
pnpm add -D @storybook/nextjs
pnpm storybook
해결되는줄 알았는데 또 오류가 터졌다.
√ Port 6006 is not available. Would you like to run Storybook on port 6007 instead? ... yes Could not resolve addon "@storybook/addon-essentials", skipping. Is it installed? Could not resolve addon "@storybook/addon-essentials", skipping. Is it installed?
@storybook/addon-essentials을 못 찾는 오류랑 .mdx 파일 로더 에러.
필수 애드온을 설치해주면 해결 된다고 한다.
pnpm add -D @storybook/addon-essentials @storybook/addon-docs
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/nextjs';
const config: StorybookConfig = {
framework: {
name: '@storybook/nextjs',
options: {},
},
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-docs',
],
};
export default config;
자 이제 됐나? ㅎㅎ
storybook v9.1.2 You are currently using Storybook 9.1.2 but you have packages which are incompatible with it: - @storybook/addon-essentials@8.6.14 which depends on 8.6.14
뭔 에러가 이렇게 많이 나는 건지;
이번엔 Storybook 버전 불일치 문제라고 한다.
@storybook/addon-essentials이 8.x라서 생긴 문제.
Storybook 9.x는 모든 스토리북 패키지가 9.x여야 동작한다.
pnpm list @storybook --depth 1
이렇게 하면 storybook 관련 패키지를 확인할 수 있다.
pnpm remove @storybook/addon-essentials
우선 버전이 낮은 애는 지워버리고
pnpm add -D @storybook/addon-essentials@9.1.2 @storybook/addon-docs@9.1.2 @storybook/addon-a11y@9.1.2 @storybook/addon-onboarding@9.1.2 @storybook/addon-vitest@9.1.2 @storybook/nextjs@9.1.2 @storybook/nextjs-vite@9.1.2
이렇게 모든 패키지를 9.1.2로 재설치한다.
(이미 9.1.2인 건 그대로 유지되고, 낮은 버전만 맞춰진다.)
pnpm store prune
pnpm install
pnpm storybook
캐시 한 번 비워주고 실행하면~ 드디어 된다.
그럼 뭐 이것저것 많이 생기는데, 예시 컴포넌트들이 src/stories 이 폴더 안에 들어있다.
난 필요 없어서 삭제함...ㅎ
레이어팝업이 확인 버튼만 있는 게 있고, 취소 + 확인 버튼만 있는 게 있어서 이걸 스토리북에서 띄워보려고 한다.
Storybook 기본 템플릿 구조
Meta 설정
const meta: Meta<typeof LayerPopup> = {
title: "Example/LayerPopup",
component: LayerPopup,
};
export default meta;
title: 스토리북 좌측 메뉴에 표시될 경로
component: 스토리북에서 보여줄 컴포넌트
export default meta: 스토리북이 스토리를 인식하게 하는 필수 구문.
Story 타입 정의
type Story = StoryObj<typeof LayerPopup>;
각 Story를 StoryObj 타입으로 지정해 TypeScript에서 props 타입을 자동으로 체크하도록 한다.
기본 Story 작성
export const Default: Story = {
render: () => {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="flex flex-col items-center gap-4 mt-8">
<button onClick={() => setIsOpen(true)}>Open</button>
<LayerPopup
open={isOpen}
onOpenChange={setIsOpen}
title="제목"
description="설명"
/>
</div>
);
},
};
- render 안에서 React 컴포넌트를 직접 반환
- useState로 팝업 열림/닫힘 상태 관리!
아 맞다, tailwind 적용하려면 .storybook/preview.ts에서 globals.css import 해줘야 한다.
// LayerPopup.tsx
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
// 1. 취소 + 확인 버튼
// 취소 - 팝업 닫기 / 확인 - 다음 페이지로 이동 (다른 함수)
// 2. 확인 버튼
// 확인 - 팝업 닫기 또는 다른 페이지로 이동
type LayerPopupProps = {
open: boolean;
onOpenChange: (open: boolean) => void; // 팝업 상태 관리
onConfirm?: () => void;
type?: "confirm" | "cancelConfirm"; // confirm: 확인 버튼만, cancelConfirm: 취소 + 확인 버튼
title: string;
description: string;
};
const LayerPopup = ({
open, // 팝업 오픈 여부
onOpenChange,
type = "confirm",
title,
onConfirm,
description,
}: LayerPopupProps) => {
const handleConfirm = () => {
if (onConfirm) onConfirm();
onOpenChange(false);
};
const closeButtonStyle =
"px-4 py-2 rounded bg-gray-200 hover:bg-gray-300 text-sm";
const confirmButtonStyle =
"px-4 py-2 rounded bg-sub-green hover:bg-main-green text-sm";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-[300px]">
<DialogHeader>
<DialogTitle className="text-base">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{type !== "confirm" ? (
<DialogFooter>
<DialogClose asChild>
<button className={closeButtonStyle}>취소</button>
</DialogClose>
<button className={confirmButtonStyle} onClick={handleConfirm}>
확인
</button>
</DialogFooter>
) : (
<DialogFooter>
<DialogClose asChild>
<button className={confirmButtonStyle} onClick={handleConfirm}>
확인
</button>
</DialogClose>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
};
export default LayerPopup;
이렇게 생긴 LayerPopup 컴포넌트
// LayerPopup.stories.tsx
import React, { useState } from "react";
import { Meta, StoryObj } from "@storybook/nextjs";
import LayerPopup from "./LayerPopup";
const meta: Meta<typeof LayerPopup> = {
title: "Example/LayerPopup",
component: LayerPopup,
};
export default meta;
type Story = StoryObj<typeof LayerPopup>;
// 1. 기본 취소 + 확인
export const CancelConfirm: Story = {
render: () => {
const [isOpen, setIsOpen] = useState(false);
const handleConfirm = () => console.log("확인 클릭됨");
return (
<div className="flex flex-col items-center gap-4 mt-8">
<button onClick={() => setIsOpen(true)}>Open Cancel+Confirm</button>
<LayerPopup
open={isOpen}
onOpenChange={setIsOpen}
title="삭제하시겠습니까?"
description="이 작업은 되돌릴 수 없습니다."
onConfirm={handleConfirm}
type="cancelConfirm"
/>
</div>
);
},
};
// 2. 확인 버튼만
export const ConfirmOnly: Story = {
render: () => {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="flex flex-col items-center gap-4 mt-8">
<button onClick={() => setIsOpen(true)}>Open Confirm Only</button>
<LayerPopup
open={isOpen}
onOpenChange={setIsOpen}
title="저장되었습니다"
description="변경 사항이 성공적으로 저장되었습니다."
type="confirm"
/>
</div>
);
},
};
// 3. 확인 버튼 누르면 페이지 이동
export const ConfirmRedirect: Story = {
render: () => {
const [isOpen, setIsOpen] = useState(false);
const handleConfirm = () => {
window.location.href = "https://example.com";
};
return (
<div className="flex flex-col items-center gap-4 mt-8">
<button onClick={() => setIsOpen(true)}>Open Redirect Popup</button>
<LayerPopup
open={isOpen}
onOpenChange={setIsOpen}
title="페이지 이동"
description="확인을 누르면 다른 페이지로 이동합니다."
onConfirm={handleConfirm}
type="cancelConfirm"
/>
</div>
);
},
};
// 4. 긴 설명 팝업
export const LongDescription: Story = {
render: () => {
const [isOpen, setIsOpen] = useState(false);
const longText = `이 팝업은 매우 긴 설명을 포함합니다.
여기에 여러 줄의 텍스트를 추가하여 레이아웃이 어떻게 반응하는지 확인할 수 있습니다.
줄바꿈도 지원되고, 내용이 길어지면 스크롤이 생길 수 있습니다.`;
return (
<div className="flex flex-col items-center gap-4 mt-8">
<button onClick={() => setIsOpen(true)}>Open Long Description</button>
<LayerPopup
open={isOpen}
onOpenChange={setIsOpen}
title="긴 설명 팝업"
description={longText}
type="cancelConfirm"
/>
</div>
);
},
};
이렇게 4가지 버전을 만들어서 추가했다.
1. 취소 + 확인 버튼 팝업
2. 확인 버튼만 있는 팝업
3. 페이지 이동하는 팝업
4. 긴 설명이 들어있는 팝업
실제로 LayerPopup 컴포넌트를 사용할 때처럼 써주면 된다.
그리고 스토리북을 보면,

이렇게 내가 설정한 버전대로 잘 뜬다!
맨날 컴포넌트 만들고 테스트 할 때 page.tsx에서 불러왔다가 삭제했다가 반복했는데,
이렇게 스토리북으로 보니까 편리하고 좋다!!!

'개발 > Next.js' 카테고리의 다른 글
| Next.js에서 네이버맵 API 연동하기 (0) | 2025.09.17 |
|---|---|
| Next.js에서 GA 연동하기 (3) | 2025.09.17 |
| Next.js에서 네이버맵 검색하고 마커 띄우기 (0) | 2025.09.07 |
| Next.js의 렌더링 방식 (CSR/SSR/SSG/ISR) (4) | 2025.08.07 |
| [Next.js] 미들웨어 (1) | 2025.08.07 |