SVG 아이콘을 효율적으로 관리하는 방법 (Vite + SVGR + 타입 자동 생성)

김가희·2025년 8월 8일
0

1. 배경

프로젝트를 하다 보면 공통 컴포넌트를 체계적으로 관리하고 싶은 욕심이 커진다.
그중 아이콘은 거의 모든 페이지에 쓰이는데, 처음엔 import로 직접 불러오고 쓰다가, 점점 유지보수 지옥이 시작된다.

  • 파일명 바뀌면 모든 곳에서 수정
  • 아이콘 추가할 때마다 import 문 복붙
  • 사용하지 않는 아이콘이 계속 코드에 남음
    .
    .
    .

이걸 해결하기 위해 고민하다가 섭승이 님의 글 Vite + SVGR + Glob Import + 타입 자동화로 개선한 아이콘 시스템을 발견했고, 딱 내가 원하던 방식으로 구현되어 있어 큰 도움을 받았다.
다만 원본은 Emotion 기반이라, 나는 Tailwind CSS 기반으로 재구현했다.

나는 다음과 같은 개선 목표를 세웠다.

  • 간편한 사용법: 아이콘 파일을 한 폴더에 모아 이름만으로 불러오기
  • 유연한 스타일링: width, color를 props로 조절 가능
  • 타입 안정성: 아이콘 타입 자동 생성 (추가/삭제 시 자동 반영)
  • 개발 효율성: 파일명 변경 시 import 경로 수정 불필요


2. 구현 과정

2-1. SVG를 React 컴포넌트로 변환하기

먼저, SVG 파일을 JSX에서 <Icon />처럼 쓰려면, SVG를 React 컴포넌트 형태로 변환해야 한다.
Vite에서는 이걸 쉽게 해 주는 플러그인이 있다.

npm i -D vite-plugin-svgr

그리고 vite.config.ts에 플러그인을 추가한다.

import svgr from 'vite-plugin-svgr';

export default defineConfig({
  plugins: [react(), svgr()],
});

이렇게 하면, ?react를 붙여 불러올 때 SVG가 바로 React 컴포넌트로 변환된다.

import CheckboxIcon from '@/assets/icons/checkbox.svg?react';

여기까지 하면 SVG는 쓸 수 있지만, 여전히 매번 import 해야 하고, 파일명이 바뀌면 전부 수정해야 하는 문제는 남아 있다.


2-2. 아이콘 맵핑 객체 만들기

그래서 한 폴더의 SVG를 한 번에 불러와서, 이름으로 접근하는 매핑 객체를 만들기로 했다.
이렇게 하면 이제 iconMap['checkbox']처럼 파일 경로 없이 바로 아이콘 컴포넌트를 꺼내 쓸 수 있다.

// src/assets/icons/icon-map.ts

import type { JSX } from 'react';

const icons = import.meta.glob<{
  default: (props: React.SVGProps<SVGSVGElement>) => JSX.Element;
}>('@assets/icons/*.svg', { query: '?react', eager: true });

export const iconMap = Object.fromEntries(
  Object.entries(icons).map(([path, { default: SvgComponent }]) => [
    path.split('/').pop()?.replace('.svg', ''),
    SvgComponent,
  ]),
) as Record<string, (props: React.SVGProps<SVGSVGElement>) => JSX.Element>;

📌 동작 방식

  • import.meta.glob@assets/icons 폴더의 .svg 전부 import
  • query: '?react' → SVG를 React 컴포넌트로 변환
  • eager: true → 바로 로드 (비동기 X)
  • path.split('/').pop()?.replace('.svg', '') → 파일명만 추출
  • Object.fromEntries{ 'checkbox': CheckboxComponent, ... } 형태로 매핑

2-3. 타입 자동 생성 스크립트

여기서 한 가지 문제가 또 있다.
아이콘을 문자열로만 받으면, 오타를 내거나 존재하지 않는 아이콘 이름을 써도 컴파일 에러가 안 난다.

이걸 방지하려면 아이콘 이름을 타입으로 제한해야 한다.
예를 들어, 'arrow' | 'checkbox' | ... 형태의 IconType을 만들면, 존재하지 않는 아이콘을 쓰면 컴파일 단계에서 바로 에러를 잡아준다.

하지만 아이콘이 추가되거나 삭제될 때마다 타입을 수동으로 수정하는 건 비효율적이다.
그래서 아이콘 폴더를 읽어 타입을 자동 생성하는 스크립트를 작성했다.

// scripts/generate-icon-types.ts

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const ICONS_DIR = path.resolve(__dirname, '../src/assets/icons');
const OUTPUT_FILE = path.resolve(__dirname, '../src/types/icon-types.ts');

function generateIconTypes() {
  const iconTypes = fs
    .readdirSync(ICONS_DIR)
    .filter((file) => file.endsWith('.svg'))
    .map((file) => file.replace('.svg', ''));

  const typeFile = `export type IconType =\n  | ${iconTypes.map((name) => `'${name}'`).join('\n  | ')};\n`;

  fs.writeFileSync(OUTPUT_FILE, typeFile);
}

generateIconTypes();

package.json에 스크립트를 등록한다.

  "generate:icon-types": "ts-node scripts/generate-icon-types.ts",
}

이제 터미널에서

npm run generate:icon-types

하면 src/types/icon-types.ts가 자동 생성된다.


2-4. 개발 중 자동 타입 갱신 (Vite 플러그인)

아이콘 타입을 자동 생성하는 스크립트(generate-icon-types.ts)를 만들어놨지만,
매번 아이콘을 추가·삭제할 때마다 npm run generate:icon-types를 수동으로 실행하는 건 상당히 귀찮다.
개발할 때는 npm run dev를 켜놓은 상태에서 자동으로 타입이 갱신되면 훨씬 편하다.

그래서 Vite의 커스텀 플러그인을 만들어,
아이콘 폴더(src/assets/icons) 안의 .svg 파일 변화가 감지되면 타입 생성 스크립트를 자동 실행하게 했다.

// scripts/vite-plugin-generate-icon-types.ts

import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

import type { Plugin } from 'vite';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export default function generateIconTypesPlugin(): Plugin {
  return {
    name: 'vite-plugin-generate-icon-types',
    apply: 'serve' as const, // dev 모드에서만 실행
    configureServer() {
      const iconsDir = path.resolve(__dirname, '../src/assets/icons');

      runGenerateScript();

      let debounceTimer: NodeJS.Timeout | null = null;

      fs.watch(iconsDir, (_, filename) => {
        if (!filename || !filename.endsWith('.svg')) return;

        if (debounceTimer) clearTimeout(debounceTimer);

        debounceTimer = setTimeout(() => {
          console.log(`🔄 Icons changed: ${filename}`);
          runGenerateScript();
        }, 300); 
      });
    },
  };
}

function runGenerateScript() {
  const scriptPath = path.resolve(__dirname, './generate-icon-types.ts');

  const command = process.platform === 'win32' ? 'npx.cmd' : 'npx';
  const args = ['ts-node', scriptPath];

  const child = spawn(command, args, {
    stdio: 'inherit',
    shell: true, // Windows에서 인자 파싱 문제 방지
  });

  child.on('error', (err) => {
    console.error('❌ Failed to run generate-icon-types script:', err);
  });
}

📌 동작 방식

  • Vite dev 서버가 실행될 때
    → runGenerateScript()로 타입 생성 스크립트를 최초 1회 실행.
  • fs.watch로 아이콘 폴더 감시
    → .svg 파일이 추가/삭제되면 이벤트 발생.
  • 디바운스 적용
    → 한 번에 여러 파일이 바뀌면 이벤트가 중복으로 발생할 수 있음.
    → 0.3초 동안 추가 이벤트를 무시해, 불필요한 스크립트 실행을 막음.
  • 타입 생성 스크립트 실행
    → spawn으로 npm run generate:icon-types 실행.
    → 변경된 아이콘 목록을 반영한 최신 IconType 타입이 생성됨.

vite.config.ts에 플러그인을 추가한다.

import generateIconTypesPlugin from './scripts/vite-plugin-generate-icon-types';

export default defineConfig({
  plugins: [
    react(), 
    svgr(), 
    generateIconTypesPlugin()
  ],
});

2-5. Icon 컴포넌트 구현

아이콘 맵핑(iconMap)과 타입(IconType)이 준비되었으니, 이제 실제로 프로젝트 어디서든 쓸 수 있는 Icon 컴포넌트를 만들면 된다.

// src/components/ui/Icon.tsx

import clsx from 'clsx';

import type { IconType } from '@/types/icon-types';
import { iconMap } from '@assets/icons/icon-map';

interface IconProps {
  name: IconType;
  width?: number;
  color?: string;
  className?: string;
  onClick?: React.MouseEventHandler<SVGSVGElement>;
}

function Icon({ name, width, color, className, onClick }: IconProps) {
  const IconComponent = iconMap[name];
  if (!IconComponent) return null;

  return (
    <IconComponent
      width={width}
      onClick={onClick}
      className={clsx(onClick && 'cursor-pointer', className)}
      style={{
        ...(color ? { color } : {}),
      }}
    />
  );
}

export default Icon;

⚠️ SVG 파일 작성 규칙

  • 색상 하드코딩 금지: fill="currentColor" 또는 stroke="currentColor"
  • width/height 제거 → 컴포넌트에서 props로 제어
  • viewBox 반드시 유지

❌ 잘못된 예시

<svg width="24" height="24" fill="#000"> ... </svg>

✅ 올바른 예시

<svg viewBox="0 0 24 24" fill="currentColor"> ... </svg>

2-6. 사용 예시

// 기본 사용법
<Icon name="checkbox" />

// 스타일 커스터마이징
<Icon
  name="checkbox"
  color="red"
  width={24}
  className="hover:opacity-80 transition-opacity"
  onClick={() => console.log('clicked!')}
/>

// Tailwind CSS 클래스와 함께
<Icon
  name="arrow"
  className="text-blue-500 hover:text-blue-700"
  width={20}
/>

2-7. 프로젝트 구조

src/
├── assets/
│   └── icons/
│       ├── icon-map.ts
│       ├── checkbox.svg
│       ├── arrow.svg
│       └── ...
├── components/
│   └── ui/
│       └── Icon.tsx
├── types/
│   └── icon-types.ts (자동 생성)
└── scripts/
    ├── generate-icon-types.ts
    └── vite-plugin-generate-icon-types.ts


3. 마무리

이번 작업을 통해 아이콘 관리가 훨씬 단순하고 안전해졌다.

  • 아이콘 추가/삭제 → 자동 타입 갱신, 실수로 잘못된 이름을 쓰면 컴파일 단계에서 에러로 확인 가능
  • 스타일링 자유도 → width, color, className으로 크기와 색상 제어 가능
  • 유지보수 용이성 → 파일명 변경이나 경로 변경 시에도 전역에서 import 수정 불필요
  • 일관성 확보 → 모든 아이콘이 동일한 규칙(currentColor, viewBox, width/height props 제어)을 따름

아이콘 컴포넌트는 작은 부분 같아도, 프로젝트 전체에서 매일같이 쓰이는 요소라 관리 구조를 한 번 잡아두면 장기적으로 유지보수 비용을 크게 줄일 수 있다.
여러분의 프로젝트에도 꼭 적용해 보길 추천한다.

0개의 댓글