๐ React Native, Web ํฌ๋ก์ค ํ๋ซํผ ๋์์ธ ์์คํ Storybook ๊ตฌ์ถํ๊ธฐ
React Native๋ก ์ฑ์ ๋ง๋ค๊ณ , ๊ฐ์ ๋์์ธ์ ์น์ฑ์๋ ์ ์ฉํด์ผ ํ๋ ์ํฉ์ด ๋ฐ๋ณต ๋์ด, ํ๊ณผ ํจ๊ป react-native-web ๊ธฐ๋ฐ์ ํฌ๋ก์ค ํ๋ซํผ ๋์์ธ ์์คํ
์ ๋ง๋ค์์ต๋๋ค. ์ปดํฌ๋ํธ๋ ์ ๋์๋๋ฐ, ๋ฌธ์ ๊ฐ ํ๋ ์์์ต๋๋ค.
22๊ฐ ์ปดํฌ๋ํธ๊ฐ ์ ๋ถ Compound Component(ํฉ์ฑ ์ปดํฌ๋ํธ) ํจํด์ด๋ผ "์ด๋ป๊ฒ ์กฐํฉํด์ ์ฐ๋ ๊ฑด๋ฐ?"๋ผ๋ ์ง๋ฌธ์ ์ฝ๋๋ง ๋ณด์ฌ์ฃผ๊ธฐ์ ํ๊ณ๊ฐ ์์์ต๋๋ค. ๊ฒฐ๊ตญ Storybook์ ์ง์ ์
์
ํ๊ณ , ๊ทธ ๊ณผ์ ์์ webpack ์ค์ ๊ณผ ๊ฝค ์ค๋ ์ธ์ ์ต๋๋ค. ๊ทธ ๊ฒฝํ์ ๊ณต์ ํด๋ณด๋ ค ํฉ๋๋ค.
ํ๊ฒฝ ์ ๋ณด
- React Native, Expo
- Storybook 8.6.4 (@storybook/react-webpack5)
- react-native-web 0.19.10
- @storybook/addon-react-native-web 0.0.27
- Yarn Workspaces (๋ชจ๋ ธ๋ ํฌ)
๐ ์ ํฌ๋ก์ค ํ๋ซํผ ๋์์ธ ์์คํ ์ด ํ์ํ๋๊ฐ
์ฑ ๋ด์์ ์น๋ทฐ๋ฅผ ํตํ ์น์ฑ ๊ธฐ๋ฅ์ด ์ ์ ๋์ด๋๊ณ ์์์ต๋๋ค. ์ฑ์ React Native๋ก, ์น์ฑ์ ๋ณ๋๋ก ๋ง๋ค๋ฉด ๊ฐ์ ๋์์ธ ์คํ์ธ๋ฐ ๊ตฌํ์ด ๋ ๋ฒ์ด ๋ฉ๋๋ค. ๋์์ธ ๋ถ์ผ์น๋ ๋น์ฐํ๊ณ , ์์ ์ฌํญ ๋ฐ์๋ ๋ ๋ฒ ํด์ผ ํ์ฃ .
react-native-web์ด ์ด ๋ฌธ์ ๋ฅผ ํด๊ฒฐํด์คฌ์ต๋๋ค. React Native ์ปดํฌ๋ํธ๋ฅผ ๊ทธ๋๋ก ์น์์ ๋ ๋๋งํ ์ ์์ผ๋, ๋์์ธ ์์คํ
์ ํ๋๋ง ๋ง๋ค๋ฉด ์ฑ๊ณผ ์น ๋ ๋ค ์ธ ์ ์์์ต๋๋ค. ์ค์ ๋ก Expo Web(expo-web)์ผ๋ก ์น์ฑ ํ๋ก์ ํธ๋ฅผ ๊ตฌ์ถํ๊ณ ๋์์ธ ์์คํ
์ ์ ์ฉํ๋, ์ฑ ๋ด ์น์ฑ ๊ธฐ๋ฅ ๊ฐ๋ฐ ์๊ฐ์ด ๊ฝค ์ค์์ต๋๋ค.
๋์์ธ ์์คํ
์์ฒด๋ ํ์ด ํจ๊ป ๊ฐ๋ฐํ์ต๋๋ค. 22๊ฐ ์ปดํฌ๋ํธ, 12๊ฐ ์ปค์คํ
ํ
, 5๊ฐ Context Provider๊น์ง ๊ตฌ์กฐ๊ฐ ์ ์กํ๋๋ฐ, ๋ฌธ์ํ ํ๊ฒฝ์ ์์์ต๋๋ค.
Compound Component ํจํด ํน์ฑ์ Button.Label, Button.Icon ๊ฐ์ ์๋ธ ์ปดํฌ๋ํธ๋ฅผ ์ด๋ป๊ฒ ์กฐํฉํ๋์ง ์๊ฐ์ ์ผ๋ก ๋ณด์ฌ์ฃผ๋ ํ๊ฒฝ์ด ํ์ํ์ต๋๋ค.
๐ ์ React Native Storybook์ด ์๋๋ผ Storybook Web์ธ๊ฐ
๐ ์ฒ์์ React Native Storybook์ผ๋ก ์์
์ฒ์ ๋ ์ฌ๋ฆฐ ๊ฑด ๋น์ฐํ @storybook/react-native์์ต๋๋ค. React Native ํ๋ก์ ํธ๋๊น RN์ฉ Storybook์ ์ฐ๋ ๊ฒ ์์ฐ์ค๋ฝ์ฃ .
๊ทธ๋ฐ๋ฐ ์ค์ ๋ก ์จ๋ณด๋ ๋ถํธํจ์ด ์์์ต๋๋ค. ์ปดํฌ๋ํธ๋ฅผ ํ์ธํ๋ ค๋ฉด ์๋ฎฌ๋ ์ดํฐ๋ฅผ ์คํํ๊ฑฐ๋ ์ค์ ํธ๋ํฐ์ ์ฐ๊ฒฐํด์ผ ํฉ๋๋ค. ๋ธ๋ผ์ฐ์ ์์ ๋ฐ๋ก ์ด์ด๋ณผ ์๊ฐ ์์ผ๋, ์ฝ๋ ์์ โ ํ์ธ โ ์์ ์ ํผ๋๋ฐฑ ๋ฃจํ๊ฐ ๋๋ ค์ง๋๋ค.
๐ ๊ธฐ์กด Storybook ๊ธฐ๋ฅ์ ๋ถ์ฌ
๋ ํฐ ๋ฌธ์ ๋ ์น Storybook์์ ๋น์ฐํ ์ธ ์ ์๋ ๊ธฐ๋ฅ๋ค์ด RN ๋ฒ์ ์์๋ ๋น ์ ธ์๊ฑฐ๋ ์ ํ์ ์ด๋ผ๋ ์ ์ด์์ต๋๋ค.
- Controls ํจ๋์์ props๋ฅผ ์ค์๊ฐ์ผ๋ก ๋ฐ๊ฟ๋ณด๊ธฐ
- Docs ๋ชจ๋์์ MDX๋ก ์ธํฐ๋ํฐ๋ธ ๋ฌธ์ ๋ง๋ค๊ธฐ
- Canvas์์ ์์ค ์ฝ๋์ ๋ ๋ ๊ฒฐ๊ณผ๋ฅผ ๋๋ํ ๋ณด๊ธฐ
๋์์ธ ์์คํ
๋ฌธ์ํ์๋ ์ด๋ฐ ๊ธฐ๋ฅ๋ค์ด ํต์ฌ์ธ๋ฐ, RN Storybook์์๋ addon ์ํ๊ณ๊ฐ ์น ๋๋น ๋ถ์กฑํ์ต๋๋ค.
ํ ์ ์ฒด๊ฐ ๋ธ๋ผ์ฐ์ ์์ ์ปดํฌ๋ํธ๋ฅผ ๋ฐ๋ก ํ์ธํ๊ณ , ๋งํฌ ํ๋๋ก ๊ณต์ ํ ์ ์์ด์ผ ํ์ต๋๋ค.
๐ Storybook Web + react-native-web ์กฐํฉ ์ ํ
์ด๋ฏธ react-native-web์ด ํ๋ก์ ํธ์ ์์ผ๋, RN ์ปดํฌ๋ํธ๋ฅผ ์น์์ ๋ ๋๋งํ๋ ๊ฑด ๊ฐ๋ฅํ ์ํ์์ต๋๋ค. ์ฌ๊ธฐ์ @storybook/addon-react-native-web์ด ๋ธ๋ฆฟ์ง(Bridge) ์ญํ ์ ํด์ค๋๋ค. React Native์ View, Text, Pressable ๊ฐ์ ์ปดํฌ๋ํธ๋ฅผ react-native-web์ผ๋ก ๋ณํํด์, Storybook์ Webpack5 ํ๊ฒฝ์์ ๋ ๋๋งํ ์ ์๊ฒ ํด์ฃผ๋ addon์
๋๋ค.
๊ฒฐ๊ณผ์ ์ผ๋ก ์น Storybook์ ๋ชจ๋ ๊ธฐ๋ฅ(Controls, Docs, Canvas, addon ์ํ๊ณ)์ ๊ทธ๋๋ก ์ฐ๋ฉด์ RN ์ปดํฌ๋ํธ๋ฅผ ๋ ๋๋งํ ์ ์๊ฒ ๋์ต๋๋ค.
๐ Storybook ํ๊ฒฝ ๊ตฌ์ฑ
๐ ๋ชจ๋ ธ๋ ํฌ ๊ตฌ์กฐ์ Storybook ์์น
ํ๋ก์ ํธ๋ Yarn Workspaces๋ก ๋ชจ๋
ธ๋ ํฌ๋ฅผ ๊ตฌ์ฑํ์ต๋๋ค. ๋ฃจํธ์ ๋์์ธ ์์คํ
๋ผ์ด๋ธ๋ฌ๋ฆฌ, example/ ๋๋ ํ ๋ฆฌ์ Expo ์์ ์ฑ๊ณผ Storybook์ด ํจ๊ป ๋ค์ด์๋ ๊ตฌ์กฐ์
๋๋ค.
design-system/ โ ๋ฃจํธ: ๋์์ธ ์์คํ
๋ผ์ด๋ธ๋ฌ๋ฆฌ
โโโ src/ โ ์ปดํฌ๋ํธ, ํ
, ํ
๋ง
โโโ example/ โ Expo ์์ ์ฑ + Storybook
โ โโโ .storybook/ โ Storybook ์ค์
โ โโโ src/stories/ โ ์คํ ๋ฆฌ ํ์ผ (.stories.tsx, .mdx)
โ โโโ package.json
โโโ package.json โ workspaces: ["example"]๋ค์ดํฐ๋ธ ๊ฐ๋ฐ์ Metro ๋ฒ๋ค๋ฌ(ํฌํธ 8081)๋ก, Storybook์ Webpack5(ํฌํธ 6006)๋ก ๊ฐ๊ฐ ๋
๋ฆฝ์ ์ผ๋ก ์คํ๋ฉ๋๋ค. ๋ฃจํธ package.json์์ workspace ์คํฌ๋ฆฝํธ๋ก ์ฐ๊ฒฐํด๋๋ฉด ํธํฉ๋๋ค.
{
"scripts": {
"storybook": "yarn workspace example storybook",
"build-storybook": "yarn workspace example build-storybook"
}
}
๐ ํต์ฌ ์ค์ : .storybook/main.ts
Storybook์ ์ง์
์ ์ธ main.ts์
๋๋ค. ํ๋ ์์ํฌ๋ @storybook/react-webpack5๋ฅผ ์ฌ์ฉํ๊ณ , ํต์ฌ์ addon ๊ตฌ์ฑ์
๋๋ค.
import type { StorybookConfig } from "@storybook/react-webpack5";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-webpack5-compiler-babel",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
"@storybook/addon-react-native-web", // RN โ ์น ๋ธ๋ฆฟ์ง
"@storybook/addon-react-native-server",
"storybook-addon-deep-controls", // ์ค์ฒฉ props ์ ์ด
"@storybook/addon-docs",
],
framework: "@storybook/react-webpack5",
// ... webpack ์ปค์คํฐ๋ง์ด์ง์ ์๋ ์น์
์์
};์ฌ๊ธฐ์ ํต์ฌ์ @storybook/addon-react-native-web์
๋๋ค. ์ด addon์ด ์์ผ๋ฉด React Native์ View, Text ๊ฐ์ ์ปดํฌ๋ํธ๋ฅผ Webpack์ด ํด์ํ์ง ๋ชปํฉ๋๋ค.
storybook-addon-deep-controls๋ ๋์์ธ ์์คํ
์์๋ ์ ์ฉํ์ต๋๋ค. Compound Component ํจํด ํน์ฑ์ ์ค์ฒฉ๋ ๊ฐ์ฒด ํํ์ props๊ฐ ๋ง์๋ฐ, ๊ธฐ๋ณธ Controls ํจ๋๋ก๋ ์ด๋ฅผ ์ ์ดํ ์ ์์ต๋๋ค. ์ด addon์ด ์ค์ฒฉ props๋ฅผ ํผ์ณ์ ๊ฐ๊ฐ ๊ฐ๋ณ ์ปจํธ๋กค๋ก ๋ณด์ฌ์ค๋๋ค.
๐ ๋์์ธ ์์คํ Provider๋ก ์คํ ๋ฆฌ ๊ฐ์ธ๊ธฐ: preview.tsx
๋์์ธ ์์คํ ์ ๋ชจ๋ ์ปดํฌ๋ํธ๋ ์ต์์ Provider ์์์ ๋์ํฉ๋๋ค. ํ ๋ง, ์์ด์ฝ ์์ , ์ค์ ๊ฐ์ Context๋ก ์ ๊ณตํ๋ Provider์ธ๋ฐ, Storybook ์คํ ๋ฆฌ์์๋ ์ด๊ฑธ ๊ฐ์ธ์ค์ผ ํฉ๋๋ค.
import { DSProvider, ToastProvider } from "my-design-system";
import { icons, illustrations } from "my-design-asset";
import { View } from "react-native";
const preview: Preview = {
decorators: [
(Story: any) => (
<DSProvider
assets={{
icons: icons,
illustrations: illustrations,
}}
>
<View style={{ flexDirection: "row", justifyContent: "center" }}>
<View style={{ width: 400 }}>
<Story />
</View>
</View>
<ToastProvider />
</DSProvider>
),
],
};400px ๋๋น์ ์ปจํ
์ด๋๋ก ๊ฐ์ธ์ ๋ชจ๋ฐ์ผ ๋ทฐ๋ฅผ ์๋ฎฌ๋ ์ด์
ํฉ๋๋ค. Provider์ ์์ด์ฝ ์์
์ ๋๊ธฐ๋ ๊ฒ๋ ์์ผ๋ฉด ์ ๋ฉ๋๋ค. ์ ๋๊ธฐ๋ฉด Icon ์ปดํฌ๋ํธ๊ฐ ์ ๋ถ ์๋ฌ๋ฅผ ๋ฑ์ต๋๋ค.
๐ Webpack ์ปค์คํฐ๋ง์ด์ง
addon ์ค์นํ๊ณ ์คํ ๋ฆฌ ํ์ผ ๋ช ๊ฐ ๋ง๋ค๋ฉด ๋๋ ์ค ์์์ต๋๋ค. ํ์ค์ webpackFinal ์ค์ ์ ๊ฝค ๋ง์ ธ์ผ ํ์ต๋๋ค.
๐ SVG ์ฒ๋ฆฌ ํ์ดํ๋ผ์ธ
React Native์์๋ react-native-svg-transformer๋ก SVG๋ฅผ ์ปดํฌ๋ํธ๋ก ๋ณํํฉ๋๋ค. Metro ๋ฒ๋ค๋ฌ๊ฐ ์ด๊ฑธ ์ฒ๋ฆฌํ์ฃ . ๊ทธ๋ฐ๋ฐ Storybook์ Webpack5๋ฅผ ์ฌ์ฉํ๋, SVG ์ฒ๋ฆฌ ํ์ดํ๋ผ์ธ์ ๋ณ๋๋ก ๊ตฌ์ฑํด์ผ ํฉ๋๋ค.
๋จผ์ ๊ธฐ์กด ์ด๋ฏธ์ง ๋ฃฐ์์ SVG๋ฅผ ์ ์ธํ๊ณ , @svgr/webpack์ผ๋ก SVG๋ฅผ React ์ปดํฌ๋ํธ๋ก ๋ณํํ๋ ๋ฃฐ์ ์ถ๊ฐํฉ๋๋ค. ์ฌ๊ธฐ์ string-replace-loader๋ฅผ ์ฒด์ด๋ํด์ ํ๋์ฝ๋ฉ๋ ์์ ๊ฐ์ ๋์ ํ
๋ง์ฉ์ผ๋ก ์นํํฉ๋๋ค.
webpackFinal: async (config) => {
// ๊ธฐ์กด ์ด๋ฏธ์ง ๋ฃฐ์์ SVG ์ ์ธ
const imageRule = config.module?.rules?.find(
(rule) => rule && typeof rule === "object" && rule.test instanceof RegExp && rule.test.test(".svg")
);
if (imageRule && typeof imageRule === "object") {
imageRule.exclude = /\.svg$/;
}
// SVG โ React ์ปดํฌ๋ํธ ๋ณํ + ์์ ์นํ
config.module?.rules?.push({
test: /\.svg$/,
use: [
{ loader: "@svgr/webpack", options: { svgo: false } },
{
loader: "string-replace-loader",
options: {
search: "#222222",
replace: "current",
flags: "g",
},
},
],
});
return config;
};#222222๋ฅผ current๋ก ์นํํ๋ ์ด์ ๋ ์์ด์ฝ์ fill ์์์ ๋ฐํ์์์ ํ
๋ง ์ปฌ๋ฌ๋ก ๋์ ๋ณ๊ฒฝํ๊ธฐ ์ํด์์
๋๋ค. SVG ์๋ณธ์ ํ๋์ฝ๋ฉ๋ ์์์ด ๋จ์์์ผ๋ฉด ํ
๋ง๊ฐ ์ ์ฉ๋์ง ์์ต๋๋ค.
๐ Node.js ํด๋ฆฌํ ๋ฌธ์
Storybook์ ์ฒ์ ์คํํ๋ฉด tty์ os ๋ชจ๋์ ์ฐพ์ ์ ์๋ค๋ ์๋ฌ๊ฐ ๋ฉ๋๋ค. ์ด ๋ชจ๋๋ค์ Node.js ๋ด์ฅ ๋ชจ๋์ธ๋ฐ, Webpack5๋ถํฐ๋ ๋ธ๋ผ์ฐ์ ํ๊ฒฝ์์ Node.js ํด๋ฆฌํ์ ์๋์ผ๋ก ํฌํจํ์ง ์์ต๋๋ค.
config.resolve.fallback = {
...config.resolve.fallback,
tty: require.resolve("tty-browserify"),
os: require.resolve("os-browserify/browser"),
};tty-browserify์ os-browserify๋ฅผ devDependencies์ ์ค์นํ๊ณ fallback์ผ๋ก ์ฐ๊ฒฐํ๋ฉด ํด๊ฒฐ๋ฉ๋๋ค.
๐ ๋ชจ๋ ธ๋ ํฌ ๋ชจ๋ ํด์ ๋ฌธ์
๋์์ธ ์์คํ
์ด ์ฐธ์กฐํ๋ ์์
ํจํค์ง์์ ๋ฌธ์ ๊ฐ ๋ฐ์ํ์ต๋๋ค. ํด๋น ํจํค์ง์ package.json์ exports ํ๋๊ฐ ์ ์๋์ด ์๋๋ฐ, Webpack์ด ์ด๋ฅผ ํด์ํ๋ ๊ณผ์ ์์ ๋ชจ๋์ ์ ๋๋ก ์ฐพ์ง ๋ชปํ์ต๋๋ค.
// ์ง์ ํ์ผ ๊ฒฝ๋ก๋ก alias ์ฐํ
config.resolve.alias = {
...(config.resolve.alias || {}),
"my-design-asset": path.resolve(__dirname, "../node_modules/my-design-asset/dist/index.js"),
};exports ํ๋๋ฅผ ์ฐํํด์ ๋น๋ ์ฐ์ถ๋ฌผ์ ์ค์ ํ์ผ ๊ฒฝ๋ก๋ก ์ง์ ๋งคํํ๋ ๋ฐฉ์์
๋๋ค. ๋ชจ๋
ธ๋ ํฌ ํ๊ฒฝ์์ ํจํค์ง ๊ฐ ์ฐธ์กฐ ์ ์ด๋ฐ ๋ฌธ์ ๊ฐ ์์ฃผ ๋ฐ์ํฉ๋๋ค.
๐ ํ๋ซํผ๋ณ ์ฐจ์ด๋ฅผ ๋์ด์
React Native ์ปดํฌ๋ํธ๋ฅผ ์น์์ ๋ ๋๋งํ๋ฉด "๋๋ถ๋ถ์" ์ ๋์ํฉ๋๋ค. ํ์ง๋ง ํ๋ซํผ ๊ฐ ๋์์ด ๋ค๋ฅธ ์์ญ์ด ์๊ณ , ๋์์ธ ์์คํ
์์๋ ์ด๊ฑธ .web.tsx ํ์ผ๋ก ๋ถ๋ฆฌํด์ ํด๊ฒฐํ์ต๋๋ค.
๐ .web.tsx ์ ๋ต
Metro ๋ฒ๋ค๋ฌ์ Webpack ๋ชจ๋ ํ๋ซํผ๋ณ ํ์ผ ํด์์ ์ง์ํฉ๋๋ค. LinearGradient.tsx์ LinearGradient.web.tsx๊ฐ ์์ผ๋ฉด, ์น ํ๊ฒฝ์์๋ ์๋์ผ๋ก .web.tsx๋ฅผ ๊ฐ์ ธ์ต๋๋ค.
๋ค์ดํฐ๋ธ (LinearGradient.tsx): react-native-linear-gradient ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฌ์ฉ
์น (LinearGradient.web.tsx): CSS linear-gradient ์ฌ์ฉ
// LinearGradient.web.tsx
const LinearGradient: React.FC<LinearGradientProps> = ({ gradient, direction = "horizontal", children }) => {
const cssGradient = useMemo(() => {
const colors = gradients[gradient].join(", ");
switch (direction) {
case "horizontal":
return `linear-gradient(to right, ${colors})`;
case "vertical":
return `linear-gradient(to bottom, ${colors})`;
default:
return `linear-gradient(to right, ${colors})`;
}
}, [gradient, direction]);
return (
<div style={{ backgroundImage: cssGradient }}>
<View>{children}</View>
</div>
);
};Modal๋ ๋ง์ฐฌ๊ฐ์ง์
๋๋ค.
๋ค์ดํฐ๋ธ์์๋ React Native์ Modal ์ปดํฌ๋ํธ๋ฅผ ์ฐ์ง๋ง, ์น์์๋ createPortal๋ก document.body์ ๋ ๋๋งํฉ๋๋ค. ๋ฐํ
์ํธ(BottomSheet)์ ์น ๋ฒ์ ์์๋ window.innerHeight๊ฐ ๋ฌธ์ ์์ต๋๋ค. ๋ ์ด์์ ๋ก๋ ์ ์ JS๊ฐ ์คํ๋๋ฉด ๋์ด ๊ฐ์ด 0์ผ๋ก ์กํ๊ธฐ ๋๋ฌธ์, fallback ์ฒ๋ฆฌ๊ฐ ํ์ํ์ต๋๋ค.
const getWindowHeight = () => {
// ๋ ์ด์์ ๋ก๋ ์ JS๊ฐ ์คํ๋๋ฉด window.innerHeight๊ฐ 0
if (typeof window !== "undefined" && window.innerHeight > 0) {
return window.innerHeight;
}
return Dimensions.get("window").height || 800;
};
๐ ์ ๋๋ฉ์ด์ ํธํ์ฑ
React Native์ Animated API์์ useNativeDriver: true ์ต์
์ ์ ๋๋ฉ์ด์
์ ๋ค์ดํฐ๋ธ ์ค๋ ๋์์ ์คํํฉ๋๋ค. ์ฑ๋ฅ์ด ์ข์ฃ . ๊ทธ๋ฐ๋ฐ ์น์์๋ ๋ค์ดํฐ๋ธ ๋๋ผ์ด๋ฒ๊ฐ ์์ต๋๋ค. useNativeDriver: true๋ฅผ ๊ทธ๋๋ก ๋๋ฉด ํฌ๋์๊ฐ ๋ฉ๋๋ค.
// usePressAnimation.ts
const supportsNativeDriver = (type: AnimationTypes) => {
const supportedAnimations: AnimationTypes[] = ["scale", "opacity", "rotate"];
return supportedAnimations.includes(type) && Platform.OS !== "web";
};
// ์ ๋๋ฉ์ด์
์คํ ์ ์กฐ๊ฑด๋ถ ์ ์ฉ
Animated.timing(animationValue, {
toValue,
duration: config.duration,
easing: config.easing,
useNativeDriver: supportsNativeDriver(type),
}).start();Platform.OS !== 'web' ํ ์ค๋ก ๋ถ๊ธฐํ๋ ๊ฐ๋จํ ํด๊ฒฐ์ฑ
์
๋๋ค. ์น์์๋ JS ์ค๋ ๋ ์ ๋๋ฉ์ด์
์ผ๋ก fallback๋์ง๋ง, Storybook ๋ฌธ์ํ ์ฉ๋๋ก๋ ์ถฉ๋ถํฉ๋๋ค.
๐ Icon ์ปดํฌ๋ํธ์ SVG gradient ID ์ถฉ๋
์ด๊ฑด Storybook ํน์ ์ ๋ฌธ์ ์์ต๋๋ค. ํ ํ์ด์ง์ ๊ฐ์ ๊ทธ๋ํฝ ์์ด์ฝ์ ์ฌ๋ฌ ๊ฐ ๋ ๋๋งํ๋ฉด SVG ๋ด๋ถ์ linearGradient ID๊ฐ ์ค๋ณต๋์ด ์์์ด ๊ผฌ์ด๋ ํ์์ด ๋ฐ์ํ์ต๋๋ค.
์์ธ์ SVG์ gradient ์ ์๊ฐ <defs> ์์์ ๊ณ ์ ๋ ID๋ฅผ ์ฌ์ฉํ๊ธฐ ๋๋ฌธ์
๋๋ค. ๊ฐ์ SVG๊ฐ ๋ ๋ฒ ๋ ๋๋ง๋๋ฉด ๊ฐ์ ID๊ฐ ๋ ๊ฐ๊ฐ ๋๊ณ , ๋ธ๋ผ์ฐ์ ๋ ๋จผ์ ์ฐพ์ ๊ฒ์ ์ฐธ์กฐํฉ๋๋ค.
// gradient ID ์ถฉ๋ ํด๊ฒฐ
const uniqueId = useId();
const suffix = uniqueId.replace(/:/g, "_");
const idMap = new Map<string, string>();
// ๋ชจ๋ gradient ์ ์ ์์์ ID์ ๊ณ ์ suffix ์ถ๊ฐ
const defsElements = svg.querySelectorAll("linearGradient, radialGradient, pattern, clipPath, mask, filter");
defsElements.forEach((el: Element) => {
const oldId = el.getAttribute("id");
if (oldId) {
const newId = `${oldId}_${suffix}`;
idMap.set(oldId, newId);
el.setAttribute("id", newId);
}
});
// url(#oldId) ์ฐธ์กฐ๋ ์ ID๋ก ์
๋ฐ์ดํธ
const allElements = svg.querySelectorAll("*");
allElements.forEach((el: Element) => {
["fill", "stroke", "clip-path", "mask", "filter"].forEach((attr) => {
const value = el.getAttribute(attr);
if (value?.includes("url(#")) {
const match = value.match(/url\(#([^)]+)\)/);
if (match?.[1] && idMap.has(match[1])) {
el.setAttribute(attr, `url(#${idMap.get(match[1])})`);
}
}
});
});React์ useId() ํ
์ผ๋ก ์ธ์คํด์ค๋ง๋ค ๊ณ ์ ํ suffix๋ฅผ ์์ฑํ๊ณ , ๋ ๋๋ง ํ SVG DOM์ ์ํํ๋ฉด์ ๋ชจ๋ ID์ url(#) ์ฐธ์กฐ๋ฅผ ์ผ๊ด ๊ต์ฒดํฉ๋๋ค. Storybook์ฒ๋ผ ํ ํ๋ฉด์ ๊ฐ์ ์์ด์ฝ์ด ์ฌ๋ฌ ๋ฒ ๋ํ๋๋ ํ๊ฒฝ์์ ํ์ํ ์ฒ๋ฆฌ์์ต๋๋ค.
๐ ์คํ ๋ฆฌ ์์ฑ ํจํด
Webpack ์ค์ ์ ๋๊ธฐ๊ณ ๋๋ฉด, ์คํ ๋ฆฌ ์์ฑ์ ๋น๊ต์ ์์ํฉ๋๋ค.
๐ Compound Component ์คํ ๋ฆฌ
๋์์ธ ์์คํ ์ ๋ชจ๋ ์ปดํฌ๋ํธ๊ฐ Compound Component ํจํด์ด๋ผ, ์คํ ๋ฆฌ์์ ์กฐํฉ ๋ฐฉ์์ ๋ณด์ฌ์ฃผ๋ ๊ฒ ํต์ฌ์ ๋๋ค.
// Button.stories.tsx
const meta: Meta<typeof Button> = {
title: "Components/Button",
component: Button,
argTypes: {
variant: {
control: "select",
options: ["primary", "secondary", "disabled"],
description: "๋ฒํผ์ ์คํ์ผ ๋ณํ์ ์ค์ ํฉ๋๋ค.",
},
size: {
control: "select",
options: ["lg", "md", "sm"],
},
isLoading: { control: "boolean" },
},
};
export const WithIcon: Story = {
render: (args) => (
<Button {...args}>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Button.Icon name="add-user" />
<Button.Label>์ถ๊ฐํ๊ธฐ</Button.Label>
</View>
</Button>
),
};Button ์์ Button.Icon๊ณผ Button.Label์ ์ด๋ป๊ฒ ์กฐํฉํ๋์ง ์๊ฐ์ ์ผ๋ก ๋ณด์ฌ์ค๋๋ค. argTypes๋ก ํ
๋ง, ํฌ๊ธฐ, ๋ก๋ฉ ์ํ๋ฅผ Controls ํจ๋์์ ์ค์๊ฐ์ผ๋ก ๋ฐ๊ฟ๋ณผ ์ ์์ต๋๋ค.
๐ MDX ๋ฌธ์ํ
์คํ ๋ฆฌ๋ง์ผ๋ก๋ "์ด ์ปดํฌ๋ํธ๋ฅผ ์ด๋ป๊ฒ ์ฐ๋ ๊ฑฐ์ผ?"๋ผ๋ ์ง๋ฌธ์ ์ถฉ๋ถํ์ง ์์ต๋๋ค. @storybook/addon-docs๋ฅผ ์ถ๊ฐํ๋ฉด MDX ํ์ผ๋ก ์ธํฐ๋ํฐ๋ธ ๋ฌธ์๋ฅผ ๋ง๋ค ์ ์์ต๋๋ค. @storybook/blocks์์ ์ ๊ณตํ๋ Canvas(๋ ๋ ๊ฒฐ๊ณผ + ์์ค ์ฝ๋)์ Controls๋ฅผ ํ ํ์ด์ง์ ๋ฐฐ์นํ๋ ๋ฐฉ์์
๋๋ค.
import { Canvas, Controls } from "@storybook/blocks";
import * as ButtonStories from "./Button.stories";
# Button
๊ธฐ๋ณธ์ ์ธ ํํ์ ๋ฒํผ ์ปดํฌ๋ํธ์
๋๋ค.
๊ฐ ์์๋ค์ ์กฐํฉํด ๋ค์ํ ํํ์ ๋ฒํผ์ ๋ง๋ค ์ ์์ต๋๋ค.
## `<Button />` (Button.Container)
<Canvas of={ButtonStories.Default} sourceState="shown" />
<Controls of={ButtonStories.Default} />
## Snippet
VSCode์์ ์๋ ๋ช
๋ น์ด๋ฅผ ์
๋ ฅํ์ฌ ์ฌ์ฉํ ์ ์์ต๋๋ค.sourceState='shown'์ผ๋ก ์์ค ์ฝ๋๋ฅผ ๊ธฐ๋ณธ ํผ์นจ ์ํ๋ก ๋ณด์ฌ์ค๋๋ค. ์ปดํฌ๋ํธ์ ๋ ๋ ๊ฒฐ๊ณผ, ์์ค ์ฝ๋, props ์ปจํธ๋กค์ด ํ ๋์ ๋ค์ด์ค๋ ๋ณ๋์ README ์์ด๋ ํ์์ด ๋ฐ๋ก ์ฌ์ฉํ ์ ์์ต๋๋ค.
์ถ๊ฐ๋ก, VSCode ์ค๋ํซ๋ ๊ฐ์ ํ์ด์ง์ ๋ฌธ์ํํด์ button-label-only ๊ฐ์ ์ค๋ํซ ๋ช
๋ น์ด๋ฅผ ๋ฐ๋ก ํ์ธํ ์ ์๊ฒ ํ์ต๋๋ค.
๐ ์คํ ๋ฆฌ ๋ฌธ์ ์์ฑ ์๋ํ
์คํ ๋ฆฌ์ MDX ๋ฌธ์์ ๊ตฌ์กฐ๋ ์ปดํฌ๋ํธ๋ง๋ค ๊ฑฐ์ ๋์ผํฉ๋๋ค.
`meta` ์ ์, `argTypes` ์ค์ , ๋ ๋ ํจ์, MDX์ `Canvas`/`Controls` ๋ฐฐ์น๊น์ง ํจํด์ด ๋ฐ๋ณต๋์ฃ . 22๊ฐ ์ปดํฌ๋ํธ์ ๋ํด ์ด๊ฑธ ๋งค๋ฒ ์๋์ผ๋ก ์์ฑํ๋ ๊ฑด ๋ฐ๋ณต ์์
์ด์์ต๋๋ค. ๊ทธ๋์ Claude Code ์คํฌ(Skill)์ ๋ง๋ค์ด ์๋ํํ์ต๋๋ค.
์ปดํฌ๋ํธ์ props ํ์
๊ณผ ์๋ธ ์ปดํฌ๋ํธ ๊ตฌ์กฐ๋ฅผ ์ฝ์ด์ `.stories.tsx`์ `.mdx` ํ์ผ์ ์๋ ์์ฑํ๋ ๋ฐฉ์์
๋๋ค. ์ปดํฌ๋ํธ๋ฅผ ์์ ํ์ ๋๋ ์คํฌ์ ์คํํ๋ฉด ๋ฌธ์๊ฐ ๊ฐ์ด ์
๋ฐ์ดํธ๋๋, ์ฝ๋์ ๋ฌธ์๊ฐ ๋ฐ๋ก ๋
ธ๋ ์ํฉ์ ์ค์ผ ์ ์์์ต๋๋ค.
๐ Docs ๋ชจ๋๋ก ๊ฐ๋ณ๊ฒ ๋น๋ํ๊ธฐ
Storybook์ ํ๋ก ํธ ๊ฐ๋ฐ์๋ง ๋ณด๋ ๋๊ตฌ๊ฐ ์๋๋๋ค. ๋์์ธํ๊ณผ๋ ๊ณต์ ๋๋ ๋ฌธ์์ด๊ธฐ๋ ํ์ฃ .
์์ MDX ๋ฌธ์ํ ์น์
์์ ๋ดค๋ฏ์ด, ํ๋์ MDX ํ์ผ์ ์ปดํฌ๋ํธ์ ๋ ๋ ๊ฒฐ๊ณผ, ์์ค ์ฝ๋, Controls, ์ค๋ํซ๊น์ง ์ ๋ถ ๋ด์๋์ต๋๋ค. ์ปดํฌ๋ํธ ํ๋์ ํ์ํ ์ ๋ณด๊ฐ MDX ํ ํ์ด์ง์ ๋ค ๋ค์ด์์ผ๋, ๊ตณ์ด ํ ์ธํฐ๋ํฐ๋ธ Storybook์ ๋น๋ํ ํ์๊ฐ ์์์ต๋๋ค.
storybook build --docs ๋ช
๋ น์ด๋ฅผ ์ฌ์ฉํ๋ฉด ์ด MDX ๋ฌธ์ ํ์ด์ง๋ง ์ ์ ์ผ๋ก ๋น๋ํฉ๋๋ค. ์ฐ์ถ๋ฌผ์ด ๊ฐ๋ณ๊ณ , ์ ์ HTML์ด๋ผ ๋ณ๋ ์๋ฒ ์์ด ์ด๋๋ ํธ์คํ
ํ ์ ์์ต๋๋ค. ๋์์ด๋๋ ๋ธ๋ผ์ฐ์ ์์ ์ปดํฌ๋ํธ๊ฐ ์ค์ ๋ก ์ด๋ป๊ฒ ๋ ๋๋ง๋๋์ง, ์ด๋ค variant๊ฐ ์๋์ง ๋ฐ๋ก ํ์ธํ ์ ์์ต๋๋ค.
์ฐ๋ค ๋ณด๋ Storybook ์ธํ
ํ๋์ webpack config, SVG ํ์ดํ๋ผ์ธ, ํด๋ฆฌํ, ๋ชจ๋ ํด์, ํ๋ซํผ๋ณ ๋ถ๊ธฐ๊น์ง ๊ฑด๋๋ฆฐ ๊ฒ ๊ฝค ๋ง์๋ค๋ ๊ฑธ ์์ผ ๋๋๋๋ค. ๋์ด์ผ๋ณด๋ฉด ํ๋ํ๋๋ ํฐ ๋ฌธ์ ๊ฐ ์๋์๋๋ฐ, ์ฒ์ ๋ง๋๋ฉด ์์ธ์ ์ฐพ๊ธฐ๊ฐ ์ฝ์ง ์์ ๊ฒ๋ค์ด์์ต๋๋ค.
๊ทธ๋๋ ํ์คํ ๊ฑด, Compound Component ํจํด์ผ๋ก ๋ง๋ ๋์์ธ ์์คํ
์์ Storybook์ ์์ ์ ์๊ฐํ ์๋ฃ๊ฐ ๊ฐ๋ฐ์ ํฐ ๋์์ด ๋์๋ค๋ ์ ์
๋๋ค. ์ฝ๋๋ง ๋ด์๋ Button.Label๊ณผ Button.Icon์ ์ด๋ป๊ฒ ์กฐํฉํ๋์ง ๊ฐ์ด ์ ์ค์ง๋ง, Storybook์์ ๋ ๋ ๊ฒฐ๊ณผ์ ์์ค ์ฝ๋๋ฅผ ๋๋ํ ๋ณด๋ฉด ๋ฐ๋ก ์ดํด๊ฐ ๋ฉ๋๋ค. React Native ํ๋ก์ ํธ์ Storybook ๋์
์ ๊ณ ๋ฏผ ์ค์ด๋ผ๋ฉด ๊ผญ ์๋ํด๋ณด์๊ธธ ๊ถํฉ๋๋ค.
'React-Native' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
| React Native์์ OpenTelemetry ์ฌ์ฉํ๊ธฐ, OTEL Web SDK๋ก ๊ตฌํํด๋ณด์ (0) | 2026.03.04 |
|---|---|
| React Native Lazy Loading์ผ๋ก ์ฑ ์์ ์๋ ๊ฐ์ ํ๊ธฐ (0) | 2025.05.19 |
| react-native-performance๋ก ์ฑ ์์ ์๊ฐ ์ธก์ ํ๊ธฐ (0) | 2025.05.17 |
| react-native ๋ฒ์ ์ ๊ทธ๋ ์ด๋ ๊ฐ์ด๋, v0.69 to v0.78 (2) | 2025.04.19 |
| 2025๋ React Native ํํฉ๊ณผ CLI vs Expo ๋น๊ต๋ถ์ (2) | 2025.01.15 |
์คํ ์ฑํ
๋๊ธ