
프로젝트를 하다 보면 공통 컴포넌트를 체계적으로 관리하고 싶은 욕심이 커진다.
그중 아이콘은 거의 모든 페이지에 쓰이는데, 처음엔 import로 직접 불러오고 쓰다가, 점점 유지보수 지옥이 시작된다.
이걸 해결하기 위해 고민하다가 섭승이 님의 글 Vite + SVGR + Glob Import + 타입 자동화로 개선한 아이콘 시스템을 발견했고, 딱 내가 원하던 방식으로 구현되어 있어 큰 도움을 받았다.
다만 원본은 Emotion 기반이라, 나는 Tailwind CSS 기반으로 재구현했다.
나는 다음과 같은 개선 목표를 세웠다.
먼저, 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 해야 하고, 파일명이 바뀌면 전부 수정해야 하는 문제는 남아 있다.
그래서 한 폴더의 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 전부 importquery: '?react' → SVG를 React 컴포넌트로 변환eager: true → 바로 로드 (비동기 X)path.split('/').pop()?.replace('.svg', '') → 파일명만 추출Object.fromEntries → { 'checkbox': CheckboxComponent, ... } 형태로 매핑여기서 한 가지 문제가 또 있다.
아이콘을 문자열로만 받으면, 오타를 내거나 존재하지 않는 아이콘 이름을 써도 컴파일 에러가 안 난다.
이걸 방지하려면 아이콘 이름을 타입으로 제한해야 한다.
예를 들어, '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가 자동 생성된다.
아이콘 타입을 자동 생성하는 스크립트(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.config.ts에 플러그인을 추가한다.
import generateIconTypesPlugin from './scripts/vite-plugin-generate-icon-types';
export default defineConfig({
plugins: [
react(),
svgr(),
generateIconTypesPlugin()
],
});
아이콘 맵핑(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>
// 기본 사용법
<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}
/>
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
이번 작업을 통해 아이콘 관리가 훨씬 단순하고 안전해졌다.
아이콘 컴포넌트는 작은 부분 같아도, 프로젝트 전체에서 매일같이 쓰이는 요소라 관리 구조를 한 번 잡아두면 장기적으로 유지보수 비용을 크게 줄일 수 있다.
여러분의 프로젝트에도 꼭 적용해 보길 추천한다.