인턴하는 곳에서 진행 중인 프로젝트를 Turborepo로 마이그레이션하면서 공통 컴포넌트를 관리하는데 Storybook을 추가하기로 했다. 또한 SVG를 React 컴포넌트로 활용할 때 SVGR을 사용하여 관리하기로 결정했다.
이번 글에서는 SVGR 세팅 과정에서 겪었던 문제와 그 해결 과정을 소개한다.
@svgr/webpack
설치 후 Storybook 실행 시 아래처럼 에러가 발생했다.
찾아보니 이미 많은 사람들이 겪어본 이슈로, Storybook의 webpackFinal
옵션에 SVGR 설정을 추가하여 해결했다.
// .storybook/main.ts
webpackFinal: async (config) => {
const imageRule = config.module?.rules?.find((rule) => {
const test = (rule as { test: RegExp }).test;
if (!test) {
return false;
}
return test.test(".svg");
}) as { [key: string]: unknown };
imageRule.exclude = /\.svg$/;
config.module?.rules?.push({
test: /\.svg$/,
use: ["@svgr/webpack"],
});
return config;
}
tsup
으로 ui 패키지를 빌드할 때 SVG가 컴포넌트가 아닌 그대로 복사되는 문제가 발생했다.
이 문제는 tsup
이 기본적으로 SVGR 처리를 하지 못하기 때문이었다. 지피티와 구글링을 한끝에 esbuild-plugin-svgr
을 발견하였고, 아래처럼 추가하여 해결했다.
import svgr from "esbuild-plugin-svgr";
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
splitting: false,
clean: true,
outDir: "dist",
target: "es6",
minify: false,
watch: process.env.WATCH === "true",
banner: {
js: '"use client"',
},
esbuildPlugins: [svgr()],
});
이렇게 설정하여 정상적으로 빌드된 SVG 컴포넌트를 apps에서 사용할 수 있게 되었다.
이 내용은 오류 해결 보다는 icon 컴포넌트 자동화에 대한 이야기이다. 개인적으로 뿌듯해서 작성했다 ㅋㅋㅋ
초기에는 SVG 파일을 수동으로 컴포넌트화하여 관리했다.
import Call from "../../public/call.svg";
// 기타 여러 개의 수동으로 관리된 import...
const Icon = { Call, /* ... */ };
export default Icon;
하지만 SVG가 계속 추가되면 관리가 어려워지고 실수가 발생할 가능성이 있어, 자동화가 필요하다는 생각이 들었다.
결과물로는 아래와 같은 스크립트를 작성했다.
import { exec } from "child_process";
import fs from "fs/promises";
import path from "path";
const publicDir = path.join(__dirname, "../public");
const kebabToPascal = (str: string) => {
return str
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join("");
};
const execAsync = (command: string): Promise<void> => {
return new Promise((resolve, reject) => {
exec(command, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
};
const generateIcons = async () => {
try {
const svgFiles = await fs.readdir(publicDir);
// Generate imports
const imports = svgFiles
.map((file) => {
const baseName = file.replace(".svg", "");
const pascalName = kebabToPascal(baseName);
return `import ${pascalName} from "../../public/${file}";`;
})
.join("\n");
// Generate object entries
const objectEntries = svgFiles
.map((file) => {
const baseName = file.replace(".svg", "");
const pascalName = kebabToPascal(baseName);
return ` ${pascalName},`;
})
.join("\n");
// Generate the complete file content
const fileContent = `${imports}\n\nconst Icon = {\n${objectEntries}\n};\n\nexport default Icon;`;
// Write to index.tsx
const outputPath = path.join(__dirname, "../src/icon/index.tsx");
await fs.writeFile(outputPath, fileContent);
console.log("Generated index.tsx successfully!");
// Format on the generated file
await execAsync("pnpm lint");
console.log("Linted index.tsx successfully!");
} catch (error) {
console.error("Error:", error);
}
};
generateIcons();
실행 순서를 보장하기 위해 exec
을 Promise로 감싸 비동기 처리했다.
스크립트를 개발 서버가 실행될 때 자동으로 실행되도록 설정했다.
// packages/ui/package.json
"scripts": {
"generate-icons": "pnpm dlx tsx utils/generate-icons.ts",
"dev": "pnpm generate-icons && tsup --watch",
"storybook": "pnpm generate-icons && storybook dev -p 6006"
}
현재 설정에서는 SVG가 추가되거나 삭제될 때마다 개발 서버를 재실행해야 변경이 적용된다. 폴더 감지를 통해 자동 재생성하는 방식으로 개선이 필요하다.
SVGR 때문에 정말 애를 많이 먹었다… turbopack으로 바뀌면서 Next.js에서 오류가 많이 났던 것 같아서 검색을 얼마나 했는지 기억도 안 난다 ㅋㅋㅋㅋㅋ 그래도 막상 다 세팅하고 나니까 재밌는 경험을 한 것 같고 너무 뿌듯하다.