Turborepo의 packages에서 SVGR + Storybook 조합 세팅해보기

GwangSoo·2025년 5월 24일
1

개인공부

목록 보기
27/34
post-thumbnail

인턴하는 곳에서 진행 중인 프로젝트를 Turborepo로 마이그레이션하면서 공통 컴포넌트를 관리하는데 Storybook을 추가하기로 했다. 또한 SVG를 React 컴포넌트로 활용할 때 SVGR을 사용하여 관리하기로 결정했다.

이번 글에서는 SVGR 세팅 과정에서 겪었던 문제와 그 해결 과정을 소개한다.

🚨 Storybook에서 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;
}

🚨 apps에서 사용 시 SVGR 빌드 오류

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에서 사용할 수 있게 되었다.

🚀 SVG Icon 컴포넌트 자동 생성하기

이 내용은 오류 해결 보다는 icon 컴포넌트 자동화에 대한 이야기이다. 개인적으로 뿌듯해서 작성했다 ㅋㅋㅋ

초기에는 SVG 파일을 수동으로 컴포넌트화하여 관리했다.

import Call from "../../public/call.svg";
// 기타 여러 개의 수동으로 관리된 import...

const Icon = { Call, /* ... */ };
export default Icon;

하지만 SVG가 계속 추가되면 관리가 어려워지고 실수가 발생할 가능성이 있어, 자동화가 필요하다는 생각이 들었다.

💡 자동화 설계 과정

  1. public 폴더 내 모든 SVG 파일 읽기
  2. 파일명을 kebab-case에서 PascalCase로 변환
  3. import 문과 객체 자동 생성
  4. 결과물을 자동 포맷팅하여 작성

결과물로는 아래와 같은 스크립트를 작성했다.

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();

실행 순서를 보장하기 위해 execPromise로 감싸 비동기 처리했다.

🔧 개발 서버 스크립트에 추가

스크립트를 개발 서버가 실행될 때 자동으로 실행되도록 설정했다.

// 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에서 오류가 많이 났던 것 같아서 검색을 얼마나 했는지 기억도 안 난다 ㅋㅋㅋㅋㅋ 그래도 막상 다 세팅하고 나니까 재밌는 경험을 한 것 같고 너무 뿌듯하다.

0개의 댓글