๐ท๏ธ๋ค์ด๊ฐ๋ฉฐ
ํด๋น ๊ธ์ "next": "14.2.6", "@storybook/nextjs": "8.4.1" ๋ฒ์ ์ ๋ฐํ์ผ๋ก ํ ํ๋ก์ ํธ์ ๋๋ค.
๐คStorybook ์ด๋?
Storybook is a frontend workshop for building UI components and pages in isolation. Thousands of teams use it for UI development, testing, and documentation. It's open source and free.
๊ณต์๋ฌธ์์์ ์ฐพ์๋ณด๋ฉด "์คํ ๋ฆฌ๋ถ์ UI ๊ตฌ์ฑ ์์์ ํ์ด์ง๋ฅผ ๋ ๋ฆฝ์ ์ผ๋ก ์ ์ํ๋ ํ๋ก ํธ์๋ ์์ ํ๊ฒฝ์ ๋๋ค. ์์ฒ ๊ฐ์ ํ์ด UI ๊ฐ๋ฐ, ํ ์คํธ, ๋ฌธ์ํ๋ฅผ ์ํด Storybook์ ์ฌ์ฉํ๊ณ ์์ผ๋ฉฐ, ์คํ ์์ค๋ก ๋ฌด๋ฃ๋ก ์ ๊ณต๋ฉ๋๋ค " ๋ผ๊ณ ๋์์๋ค.
โStorybook ์ธ์ ,์ ์ฌ์ฉํ๋๊ฐ?
๊ทธ๋ ๋ค! ์คํ ๋ฆฌ๋ถ์ ๋ถ๋ฆฌ ํ๊ฒฝ์์ UI ์ปดํฌ๋ํธ๋ฅผ ๊ฐ๋ฐํ ์ ์๋ค. ๋ํ UI ์ปดํฌ๋ํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ๋ฌธ์ํ ํ ์๋ ์๊ณ , ๋์์ธ ์์คํ ์ ๊ฐ๋ฐํ๊ธฐ ์ํด ์ฌ์ฉํ ์๋ ์๋ค.
๊ทธ๋ ๊ธฐ ๋๋ฌธ์ Storybook์ UI ์ปดํฌ๋ํธ๋ฅผ ๋น ๋ฅด๊ณ ๋ ๋ฆฝ์ ์ผ๋ก ๊ฐ๋ฐํ ๋ ๋งค์ฐ ์ ์ฉํ๊ฒ ์ฌ์ฉ๋๋ค. ๋ํ ํ ์คํธ ์๋ํ ๋ฐ ๋ฌธ์ํ์๋ ์ ํฉํ์ฌ, ํนํ ํ ๋จ์ ํ์ ์ ๊ฐ๋ฐ ํจ์จ์ฑ๊ณผ ์ ์ง๋ณด์์ฑ์ ํฌ๊ฒ ํฅ์์ํฌ ์ ์๋ค.
๊ทธ๋์ ์ด๋ฒ ํ์ด๋ ํ๋ก์ ํธ์์ ๊ณตํต์ผ๋ก ๋ค์ด๊ฐ ๋ชจ๋ฌ์ด๋ ๋ฒํผ์ด ๋ง์ ์คํ ๋ฆฌ๋ถ์ ์ฌ์ฉํ์ฌ ํ๋ก์ ํธ๋ฅผ ์งํํ๊ธฐ๋ก ํ๋ค.
โ Next.js์ Storybook ์ฌ์ฉํ๊ธฐ
storybook ์ ํ ํ๊ธฐ
์ฐ๋ฆฌ ํ๋ก์ ํธ์์๋ pnpm์ฌ์ฉํด์ ๋ช ๋ น์ด๊ฐ ๋ค๋ฅด๋ฉด ํด๋น ์ฌ์ดํธ๋ฅผ ๋ค์ด๊ฐ์ ์์ ์ ํ๋ก์ ํธ์ ๋ง๋ ํจํค์ง ๋ช ๋ น์ด๋ฅผ ์ฌ์ฉํ๋ฉด ๋๋ค.
https://storybook.js.org/docs/get-started/frameworks/nextjs?renderer=react
pnpm dlx storybook@latest init
ํด๋น ๋ช ๋ น์ด๋ run๊น์ง ํ๊บผ๋ฒ์ ์คํ๋๊ณ , ์ค์นํ๊ณ ๋์๋ ์คํ ๋ช ๋ น์ด๋ง ์ฌ์ฉํ๋ฉด ๋๋ค.
pnpm storybook
์ค์น ํ์๋ ํด๋น ๋ช ๋ น์ด๋ง ์น๋ฉด ์คํ ๋ฆฌ๋ถ์ด ์คํ๋๋ค. ๋ค์์ ์คํํ๋ฉด์ด๋ค.
storybook UI๊ตฌ์กฐ
์คํ ๋ช ๋ น์ด ํ์๋ .storybookํด๋์ storiesํด๋๊ฐ ์๊ธด ๊ฒ์ ํ์ธํ ์ ์๋ค.
main.ts
import type { StorybookConfig } from "@storybook/nextjs";
import { join, dirname } from "path";
/**
* This function is used to resolve the absolute path of a package.
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
*/
function getAbsolutePath(value: string): any {
return dirname(require.resolve(join(value, "package.json")));
}
const config: StorybookConfig = {
stories: [
"../stories/**/*.mdx",
"../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)",
],
addons: [
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-essentials"),
getAbsolutePath("@chromatic-com/storybook"),
getAbsolutePath("@storybook/addon-interactions"),
],
framework: {
name: getAbsolutePath("@storybook/nextjs"),
options: {},
},
staticDirs: ["../public"],
};
export default config;
main.ts ์๋ storybook์ ์ํ config ์ค์ ๋ค์ด ๋ด๊ฒจ์๋ค. pnpm dlx storybook@latest init ์ ํตํด์ ๊ธฐ๋ณธ์ผ๋ก ์ค์ ๋๋ stories์ addons ์ธํ ์ด ๋์ด์๋ค.
์ฃผ์ ํจ์, ์์ฑ๋ค
getAbsolutePath ํจ์
- ํจํค์ง์ package.json ํ์ผ์ ๊ธฐ์ค์ผ๋ก ์ ๋ ๊ฒฝ๋ก๋ฅผ ๋ฐํํ๋ ์ญํ
- ๋ชจ๋ ธ๋ ํฌ๋ก ๊ตฌ์ฑ๋์ด ์๋ ํ๋ก์ ํธ์ ํจํค์ง ์์น๋ฅผ ๋ช ํํ๊ฒ ์ฐพ๊ธฐ ์ํด ์ ๋ ๊ฒฝ๋ก๊ฐ ํ์
- require.resolve๋ก ํจํค์ง๋ฅผ ์ฐพ๊ณ , dirname์ผ๋ก ํด๋น ํจํค์ง์ ์์ ๋๋ ํฐ๋ฆฌ ๊ฒฝ๋ก๋ฅผ ๋ฐํ
stories ์์ฑ
- Storybook์์ ์ฌ์ฉํ ์คํ ๋ฆฌ ํ์ผ์ ๊ฒฝ๋ก๋ฅผ ์ง์
- ../stories ํด๋ ์๋์ ๋ชจ๋ mdx ํ์ผ๊ณผ ๋ค์ํ ํ์ฅ์(js, jsx, mjs, ts, tsx)๋ฅผ ๊ฐ์ง ์คํ ๋ฆฌ ํ์ผ์ ํฌํจํ๋๋ก ์ค์
addons ์์ฑ
Storybook ๊ธฐ๋ฅ์ ํ์ฅํ๋ ์ ๋์จ๋ค์ด ํฌํจ๋์ด ์๋ค.
์์
- @storybook/addon-onboarding: Storybook ์์ ๊ฐ์ด๋ ์ ๊ณต
- @storybook/addon-essentials: ๊ธฐ๋ณธ์ ์ธ ์คํ ๋ฆฌ๋ถ ๊ธฐ๋ฅ ์ถ๊ฐ
- @storybook/addon-interactions: ์ํธ์์ฉ ํ ์คํธ ์ง์
framework ์์ฑ
- Storybook์ด ์ฌ์ฉํด์ผ ํ๋ ํ๋ ์์ํฌ๋ฅผ ์ง์
- Next.js์ฉ Storybook์ ์ค์ ํ๊ธฐ ์ํด @storybook/nextjs ํ๋ ์์ํฌ๋ฅผ ์ ๋ ๊ฒฝ๋ก๋ก ์ง์
preview.ts
import type { Preview } from "@storybook/react";
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;
preview.ts๋ Storybook์์ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ๊ฒฝ์ ์ค์ ํ๋ ๋ฐ ์ฌ์ฉ๋๋ฉฐ, ๊ฐ ์คํ ๋ฆฌ์ ๊ธฐ๋ณธ ๋งค๊ฐ๋ณ์์ ์์ฑ์ ์ ์ํ๋ค.
controls ์์ฑ
- ์ปดํฌ๋ํธ์ props๋ฅผ Storybook์์ ์กฐ์
- color: ๋ฐฐ๊ฒฝ์์ด๋ ํ ์คํธ ์์์ฒ๋ผ ์ปฌ๋ฌ ๊ด๋ จ props๋ฅผ ์๋ ๊ฐ์งํ์ฌ ์์ ์ ํ๊ธฐ๋ฅผ ์ ๊ณตํฉ๋๋ค.
- date: Date๋ก ๋๋๋ props๋ฅผ ๊ฐ์งํด ๋ ์ง ์ ํ๊ธฐ๋ฅผ ์ ๊ณตํฉ๋๋ค.
matchers ์์ฑ
- color
- /(background|color)$/i ์ ๊ท ํํ์์ ์ฌ์ฉํด prop ์ด๋ฆ์ด background๋ color๋ก ๋๋๋ ๊ฒฝ์ฐ๋ฅผ ์๋์ผ๋ก ๊ฐ์ง
- ํด๋น props๋ ์ปฌ๋ฌ ์ ํ ์ปจํธ๋กค์ด ์ ์ฉ๋์ด Storybook์์ ์์์ ์ ํํ ์ ์์
- date
- /Date$/i ์ ๊ท ํํ์์ ํตํด prop ์ด๋ฆ์ด Date๋ก ๋๋๋ ๊ฒฝ์ฐ๋ฅผ ์๋์ผ๋ก ๊ฐ์ง
- ๊ฐ์ง๋ props๋ ๋ ์ง ์ ํ ์ปจํธ๋กค์ด ์ ์ฉ๋์ด Storybook์์ ๋ ์ง๋ฅผ ์ ํํ ์ ์์
storybook ์ ์ฉํ๊ธฐ
Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
},
args: { onClick: action('clicked') },
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: {
primary: true,
label: '์ ํ ์๋ฃ',
},
};
export const LargePrimary: Story = {
args: {
primary: true,
size: 'large',
label: '๊ทธ๋ฃน ์ ํ ์๋ฃ',
},
};
export const SmallPrimary: Story = {
args: {
primary: true,
size: 'small',
label: '๋ถ๋ฌ์ค๊ธฐ',
},
};
import type { Meta, StoryObj } from '@storybook/react'
- Meta: ์คํ ๋ฆฌ์ ๋ฉํ๋ฐ์ดํฐ๋ฅผ ์ค์ . ์ฃผ๋ก ์คํ ๋ฆฌ์ ์ ๋ชฉ, ์ปดํฌ๋ํธ, ๊ธฐํ ์ค์ ๋ค์ด ํฌํจ
- StoryObj: ๊ฐ๋ณ ์คํ ๋ฆฌ์ ์์ฑ ํ์ ์ ์ ์. ๊ฐ ์คํ ๋ฆฌ์์ ์ปดํฌ๋ํธ๋ฅผ ์ด๋ป๊ฒ ๋ ๋๋งํ ์ง๋ฅผ ์ค์ ํ ์ ์์
import { action } from '@storybook/addon-actions';
- @storybook/addon-actions์์ action ํจ์๋ฅผ ๊ฐ์ ธ์ด. ์ด ํจ์๋ ์ฌ์ฉ์๊ฐ ๋ฒํผ์ ํด๋ฆญํ์ ๋ ๋ก๊ทธ๋ฅผ ๊ธฐ๋กํ๊ฑฐ๋ ์ด๋ฒคํธ๋ฅผ ์ถ์ ํ๋ ๋ฐ ์ฌ์ฉ
- ๋ฒํผ ํด๋ฆญ๊ณผ ๊ฐ์ ์ด๋ฒคํธ๋ฅผ ์ถ์ ํ๋ ๋ฐ ์ ์ฉ. ์ฌ๊ธฐ์๋ onClick ์ด๋ฒคํธ๋ฅผ ์ถ์ ํ์ฌ Storybook์ "Actions" ํจ๋์ ์ถ๋ ฅ
const meta: Meta<typeof Button> = { ... };
- meta ๊ฐ์ฒด, ์คํ ๋ฆฌ์์ ์ปดํฌ๋ํธ์ ๋ฉํ๋ฐ์ดํฐ๋ฅผ ์ ์ - ์ฃผ์ ํญ๋ชฉ
- parameters: Storybook์์ ์ค์ ํ ์ ์๋ ๋ค์ํ ์ถ๊ฐ ์ต์ . layout: 'centered'๋ก ์ค์ ํ๋ฉด ๋ฒํผ์ด ํ๋ฉด ์ค์์ ์์น
- tags: ์๋ ๋ฌธ์ํ ๊ธฐ๋ฅ์ธ autodocs ํ๊ทธ๊ฐ ์ถ๊ฐ๋์ด, Storybook์ ์๋ ๋ฌธ์ํ ์์คํ ์ ์ํด ์ด ์ปดํฌ๋ํธ์ ๋ํ ์ค๋ช ์ด ์๋์ผ๋ก ์์ฑ
- argTypes: ๊ฐ ์คํ ๋ฆฌ์์ ์ฌ์ฉ๋ argType์ ์ ์ํ๋๋ฐ ์ฌ์ฉ. argTypes๋ ์คํ ๋ฆฌ์ ์ ์ด๋ฅผ ์ํ ํ์ ์ ์ ์ํ๋ ๋ฐ ์ฌ์ฉ
- args: Button ์ปดํฌ๋ํธ์ ๊ธฐ๋ณธ ์ธ์ ๊ฐ์ ์ค์ . ์ฌ๊ธฐ์๋ onClick ์ด๋ฒคํธ๊ฐ ๋ฐ์ํ์ ๋ action('clicked')๊ฐ ํธ์ถ๋๋๋ก ์ค์ ํ์ฌ ๋ฒํผ ํด๋ฆญ์ ์ถ์
์ฌ๋ฌ ์คํ ๋ฆฌ๋ค(Primary,Secondary, LargePrimary๋ฑ);
- ๊ฐ ์คํ ๋ฆฌ๋ Button ์ปดํฌ๋ํธ์ ๋ค์ํ ๋ณํ์ ๋ณด์ฌ์ฃผ๋ ์์
- args๋ฅผ ์ฌ์ฉํ์ฌ ๋ฒํผ์ ์์ฑ์ ์ค์ , ์คํ์ผ๊ณผ ํ ์คํธ ๋ฑ์ ๋ฐ๊ฟ์ ์์ฐ
Button.tsx ์ปดํฌ๋ํธ
"use client";
import React from "react";
import { motion } from "framer-motion";
import styles from "./button.module.css";
export interface ButtonProps {
primary?: boolean;
size?: "small" | "medium" | "large" | "xlarge";
label: string;
onClick?: () => void;
className?: string;
}
export const Button = ({
primary = false,
size = "medium",
label,
...props
}: ButtonProps) => {
const mode = primary ? styles.primary : styles.secondary;
return (
<motion.button
type="button"
className={[styles[size], mode, styles.button].join(" ")}
transition={{
duration: 1,
ease: "easeInOut",
repeat: Infinity,
repeatType: "mirror",
}}
{...props}
>
{label}
</motion.button>
);
};
export interface ButtonProps { ... }
- ButtonProps
- primary: ๋ฒํผ ์คํ์ผ (๊ธฐ๋ณธ๊ฐ์ false)
- size: ๋ฒํผ ํฌ๊ธฐ (small, medium, large, xlarge ์ค ์ ํ ๊ฐ๋ฅ, ๊ธฐ๋ณธ๊ฐ์ medium)
- label: ๋ฒํผ์ ํ์ํ ํ ์คํธ
export const Button = ({ ... }: ButtonProps) => { ... }
- ์ด ์ปดํฌ๋ํธ๋ ButtonProps ํ์ ์ ๋ฐ์ผ๋ฉฐ, ๊ธฐ๋ณธ๊ฐ์ ์ค์ ํ์ฌ props๋ฅผ ๊ตฌ์กฐ ๋ถํด ํ ๋น
- primary, size, label์ ๊ธฐ๋ณธ๊ฐ์ ์ง์ ํ๊ณ , props๋ฅผ ๋๋จธ์ง๋ก ๋ฐ์์ ์ฌ์ฉ
return ( ... )
- className: ๋ฒํผ์ ์ ์ฉํ CSS ํด๋์ค๋ฅผ ์ค์ . styles[size]๋ก ๋ฒํผ ํฌ๊ธฐ๋ฅผ, mode๋ก ๋ฒํผ ์คํ์ผ์ ์ค์ ํ๊ณ , styles.button์ ํญ์ ์ถ๊ฐ
- ...props: ๋๋จธ์ง props๋ฅผ ๋ฒํผ ์๋ฆฌ๋จผํธ์ ์ ๋ฌ. ์ด๋ก ์ธํด onClick๊ณผ ๊ฐ์ ๋ค๋ฅธ props๊ฐ ์ ๋ฌ๋ ์ ์์
- label: ๋ฒํผ์ ํ ์คํธ๋ฅผ ํ์
๐ค๋ง์น๋ฉฐ
ํ๋ฌ๋์ ํ๋ก์ ํธ๋ฅผ ์งํํ๋ฉด์ ์ด ์ ๋ ๋ณผ๋ฅจ์ ํ๋ก์ ํธ์์ ์คํ ๋ฆฌ๋ถ์ ์ฌ์ฉํ๋๊ฒ ๋ง๋? ์คํ๋ ค ์๊ฐ ๋ญ๋น๊ฐ ์๋๊น๋ผ๋ ์๊ตฌ์ฌ์ด ๋ค์์ง๋ง ์ทจ์ค์ ์ ์ฅ์์ ๋ค์ํ ๊ฒฝํ์ ์๋ ๊ฒ์ด ์ค์ํ๋ค๊ณ ํ๋จํ๋ค. ์์ง ์คํ ๋ฆฌ๋ถ์ ํ์ฉํ ๋ฒ์๊ฐ ๊ทธ๋ ๊ฒ ๋์ง ์์ ํ๋ก์ ํธ๋ฅผ ๊ณ์ ์งํํ๋ฉด์ ๋ ํจ๊ณผ์ ์ผ๋ก ํ์ฉํ ์ ์๋ ๋ฐฉ๋ฒ์ ์ฐพ๊ณ ์ํ๋ค!!
์ฐธ๊ณ ์๋ฃ