I turn coffee into code ☕ → 💻 자세히보기

개발/Next.js

Next.js + Storybook 연동 기록

xuwon 2025. 8. 15. 20:33

공통 컴포넌트가 많은 프로젝트는 아니긴 하지만...
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에서 불러왔다가 삭제했다가 반복했는데,
이렇게 스토리북으로 보니까 편리하고 좋다!!!