Figma 플러그인, 스크립트로 SVG 자동화하기

반복적인 일을 자동화하여 생산성을 높여보자

2025-02-02 |

10

회사에서 디자이너가 피그마에 아이콘을 모아둔 Set를 선택하면 React 컴포넌트로 만들어주는 피그마 플러그인을 사용하고 있었습니다.

하지만, 다음과 같은 다소 불편한 점들이 있었습니다.

  1. Inline 형태의 React Component만 만들어주는 것이 아니기 때문에, 처음 프로젝트를 init한 후 새로운 아이콘이 추가되었을 때 플러그인은 무용지물이 됩니다. (즉, 일회용)
  2. 위의 플러그인은 Icon 컴포넌트에 아이콘을 한 곳에 모두 import하고 ts-pattern 으로 return하는 구조입니다. 현재 import하는 아이콘이 540개인데, 트리쉐이킹이 되지 않아 Icon 컴포넌트를 import 했을 때 타입 추론이 매우 느려지는 불편함이 있었습니다. (+ 번들사이즈 증가)

저는 새로운 아이콘이 추가될 때마다 Figma에서 아이콘의 svg 코드를 복사한 후 React Component를 직접 작성하는 과정을 반복했습니다. 이 과정을 반복할 때마다 매우 비효율적인 일이라고 느껴졌고 시간이 너무 아까웠습니다.

피그마 플러그인 개발

우선, 피그마에서 로컬까지 SVG 코드를 끌고 오는 비효율적인 과정을 자동화 하기 위해 Figma 플러그인을 개발했습니다. 회사에서는 Bitbucket을 사용하고 있는데 Bitbucket API 를 피그마 플러그인에 연동해서 원하는 레포지토리에 PR을 생성할 수 있었습니다. 소스코드

화면 기록 2025-02-02 오후 6.46.35.gif

플러그인은 다음과 같은 과정을 거칩니다.

  1. 피그마에서 선택된 Icon 메타데이터를 통해 svg 파일을 가져옵니다.
  2. 레포지토리에 브랜치를 생성합니다.
  3. 브랜치에 커밋을 생성하고 원하는 Base 브랜치에 PR을 생성합니다.

이렇게 하면 로컬에서 svg를 복붙하는 과정 없이 아이콘을 추가할 수 있습니다. 더 나아가면, 디자이너가 개발자에게 "새로운 아이콘이 추가되었어요." 라는 커뮤니케이션 조차 생략할 수 있게 돼요.

100개 이상의 파일을 선택했을 때 커밋을 생성하는 속도가 너무 느려 Timeout이 발생하거나, Bitbucket API에서 허용하는 Content-length를 초과하여 에러가 발생하는 문제가 있었습니다. 이럴 때는 직접 로컬에서 svg를 내보내기 해야 했습니다.

스크립트 개발

하지만, 위 플러그인은 원하는 디렉토리에 svg 파일만 추가해줍니다. 원하는 Inline 형태의 React Component로 사용하기 위해서는 스크립트를 개발해야 했습니다.

@svgr/core 모듈을 사용하여 스크립트를 간편하게 작성할 수 있었습니다. 소스 코드

typescript

import { transform } from '@svgr/core'; import { program } from 'commander'; import * as fs from 'fs/promises'; import * as path from 'path'; const SVG_DIR = path.join(process.cwd(), 'public/svgs'); const COMPONENT_DIR = path.join( process.cwd(), 'src/components/common/icons/svgs', ); program .option('-c, --current-color', 'SVG 색상을 currentColor로 변환') .option('-f, --force', '기존 파일 덮어쓰기') .parse(process.argv); const options = program.opts(); async function generateSvgComponents() { try { await fs.mkdir(COMPONENT_DIR, { recursive: true }); const files = await fs.readdir(SVG_DIR); const svgFiles = files.filter((file) => file.endsWith('.svg')); const componentNames: string[] = []; for (const file of svgFiles) { const componentName = path .basename(file, '.svg') .replace(/[^a-zA-Z0-9-]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .split('-') .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(''); const svgContent = await fs.readFile(path.join(SVG_DIR, file), 'utf-8'); const componentPath = path.join(COMPONENT_DIR, `${componentName}.tsx`); const fileExists = await fs .access(componentPath) .then(() => true) .catch(() => false); if (fileExists && !options.force) { console.log( `⚠️ ${componentName} 컴포넌트가 이미 존재합니다. 건너뜁니다.`, ); continue; } const componentCode = await transform( svgContent, { plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'], typescript: true, exportType: 'named', jsxRuntime: 'automatic', namedExport: componentName, svgoConfig: { plugins: [ { name: 'convertColors', params: { currentColor: options.currentColor, }, }, ], }, }, { componentName, }, ); const prettier = await import('prettier'); const formattedCode = await prettier.format(componentCode, { parser: 'typescript', semi: false, singleQuote: true, trailingComma: 'all', plugins: [], }); await fs.writeFile( path.join(COMPONENT_DIR, `${componentName}.tsx`), formattedCode, ); componentNames.push(componentName); } const indexPath = path.join(COMPONENT_DIR, 'index.ts'); let existingContent = ''; try { existingContent = await fs.readFile(indexPath, 'utf-8'); } catch { existingContent = ''; } if (!componentNames.length) { console.log('⚠️ 생성할 아이콘이 없습니다. 종료합니다.'); return; } const exportStatements = componentNames .map((name) => `export { ${name} } from './${name}'`) .join('\n'); const existingExports = existingContent.split('\n').filter(Boolean); const newExports = exportStatements .split('\n') .filter((line) => !existingExports.includes(line)); if (newExports.length > 0) { const updatedContent = [...existingExports, ...newExports, ''].join('\n'); await fs.writeFile(indexPath, updatedContent); console.log( `${componentNames.map((name) => `✅ ${name} 추가됨`).join('\n')}`, ); } console.log('✅ SVG 컴포넌트 생성 완료!'); } catch (error) { console.error('에러 발생:', error); } } generateSvgComponents();

위부터 차례대로 흐름을 대략적으로 읊어보겠습니다.

  1. 스크립트에 currentColor 옵션이 있는지 확인합니다. currentColor 옵션이 있다면 @svgrtransform 모듈에서 currentColor로 변환하는 과정을 거칩니다.
  2. 파일 이름에 특수문자가 있다면 모두 대시(-)로 치환하고 컴포넌트 이름을 PascalCase로 변환합니다.
  3. 이미 컴포넌트가 존재하는지 확인하고 존재하지 않는다면 컴포넌트를 생성합니다.
  4. 컴포넌트를 생성한 다음 prettier를 통해 포맷팅을 진행합니다.
  5. index.ts 배럴 파일에 export 구문을 추가합니다.

아이콘 생성 결과

typescript

import { forwardRef } from 'react'; import * as Icons from './icons'; type IconName = keyof typeof Icons; type IconProps = React.SVGProps<SVGSVGElement> & { name: IconName; }; function Icon(props: IconProps, ref: React.Ref<SVGSVGElement>) { const { name, width = 24, height = 24, fill = 'none', ...rest } = props; const IconElement = Icons[name]; return ( <IconElement ref={ref} width={width} height={height} fill={fill} style={{ flexShrink: 0, }} {...rest} /> ); } export default forwardRef(Icon);

그렇다면 해당 Icon 컴포넌트를 통해 다음과 같이 사용할 수 있습니다.

typescript

export default function App() { return <Icon name="IconName" />; }

배럴 파일을 통해 아이콘 name의 타입을 자동으로 추론할 수 있게 되고 Icon 컴포넌트에서 지정된 IconElement를 리턴할 수 있게 됩니다.

Prettier 모듈과 트러블 슈팅

@svgr/plugin-prettier를 통해 포맷팅을 적용하려고 했는데, 해당 플러그인은 ESModule만을 지원했기 때문에 약간의 어려움이 있었습니다. 처음에는 @svgr/plugin-prettier 플러그인을 통해 포맷팅을 적용하려고 했습니다. 내부 코드를 보니, 현재 workspace에 존재하는 prettierrc 파일을 찾아 포맷팅을 적용하는 과정을 거치고 있었습니다.

JSON

{ "semi": false, "plugins": ["prettier-plugin-tailwindcss"] }

저는 prettier-plugin-tailwindcss 플러그인을 통해 tailwindcss 포맷팅도 적용하고 있었습니다. 하지만 해당 모듈은 CommonJS 방식을 사용하고 있기 때문에 충돌이 발생했습니다.

따라서, 이와 같이 직접 prettier를 import하여 직접 포맷팅을 적용하는 방식으로 해결할 수 있었습니다.

typescript

const prettier = await import('prettier'); const formattedCode = await prettier.format(componentCode, { parser: 'typescript', semi: false, singleQuote: true, trailingComma: 'all', plugins: [], });

마치며

개발자에게는 불편함과 그것을 개선하려는 욕구 두 가지가 동시에 가져야 할 소양이라고 생각되는 것 같습니다.

피그마에서 SVG를 복사해 React Component를 만들고 이러한 반복적인 단순 작업은 어찌보면 당연한 과정으로 생각할 수 있습니다. 하지만, "개발환경을 어떻게 하면 개선할 수 있을까?" 라는 의식과 고민을 하게 됨으로써 플러그인과 스크립트를 통해 생산성을 한 층 더 높여질 수 있게 되었고 개인적으로도 성장할 수 있었던 것 같습니다.

이번 기회를 통해서 자동화에 대해 많은 관심이 생긴 계기도 된 것 같습니다. 항상 DX에 관심이 많았지만 코드적으로만 접근할 생각만 했는데 새로운 방식으로도 접근할 수 있었던 것 같습니다.