이 글은 누구를 위한 것인가
- 프로젝트마다 아이콘 파일을 개별로 import해서 번들 크기가 늘어나는 팀
- Figma 아이콘이 코드와 자주 불일치해
최신 아이콘 어디서 받나요?질문이 반복되는 팀 - 다크모드나 테마 전환 시 아이콘 색상을 일일이 바꾸고 있는 프론트엔드 엔지니어
아이콘 시스템이 왜 필요한가
아이콘을 체계 없이 다루면 이런 문제가 생긴다.
// 팀원 A
import ArrowIcon from '@/assets/icons/arrow.svg';
// 팀원 B
import { ArrowRight } from 'lucide-react';
// 팀원 C
<img src="/icons/arrow-right.png" alt="arrow" />
- 동일한 아이콘이 세 가지 방식으로 혼재
- Figma 업데이트가 코드에 반영되지 않음
- 다크모드에서 PNG 아이콘이 색상 대응 불가
- 번들에 중복 아이콘 포함
아이콘 시스템은 단일 소스(Figma) → 자동 변환 → 코드 사용까지 일관된 파이프라인을 만드는 작업이다.
1. 아이콘 제공 방식 비교
방식 1: SVG 스프라이트 (Sprite Sheet)
모든 아이콘을 하나의 SVG 파일에 <symbol> 로 모아두고, HTML에서 <use> 로 참조하는 방식이다.
<!-- /public/sprite.svg -->
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="icon-arrow-right" viewBox="0 0 24 24">
<path d="M9 18l6-6-6-6"/>
</symbol>
<symbol id="icon-check" viewBox="0 0 24 24">
<path d="M20 6L9 17l-5-5"/>
</symbol>
</svg>
<!-- 사용 -->
<svg class="icon" aria-hidden="true">
<use href="/sprite.svg#icon-arrow-right" />
</svg>
장점
- 초기 번들에 SVG가 포함되지 않음 (별도 파일 캐싱)
- HTTP/2 환경에서 스프라이트 파일 한 번만 요청
- 아이콘 수 증가해도 번들 크기 영향 없음
단점
<use>내부 스타일링 제약 (CSS로fill,stroke변경 가능하나 복잡한 멀티컬러 불가)- SSR 환경에서 외부 파일 참조 시 보안 정책(CORS) 확인 필요
방식 2: 인라인 SVG 컴포넌트
SVG 코드를 React 컴포넌트로 변환해 직접 인라인 렌더링한다.
// ArrowRight.tsx (svgr로 자동 변환)
import { SVGProps } from 'react';
export function ArrowRight(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" {...props}>
<path d="M9 18l6-6-6-6" strokeWidth={2} strokeLinecap="round" />
</svg>
);
}
장점
currentColor를 활용해 CSScolor만으로 아이콘 색상 제어 가능- 트리 쉐이킹으로 사용하는 아이콘만 번들에 포함
- TypeScript 자동완성 지원
단점
- 아이콘이 많아지면 번들 크기 주의 (코드 분할 필요)
- 동일 아이콘 여러 번 사용 시 DOM에 SVG 중복 삽입
방식 3: 아이콘 폰트 (Icon Font)
Font Awesome, Material Icons 등 웹폰트 방식.
2026년 기준 추천하지 않음. 렌더링 깜박임(FOUT), 접근성 문제, 트리 쉐이킹 불가 등의 이유로 SVG 방식이 표준이다.
방식 선택 기준
| 기준 | SVG 스프라이트 | 인라인 컴포넌트 |
|---|---|---|
| 아이콘 100개 이상 | ✅ 추천 | ⚠ 번들 주의 |
| 멀티컬러 아이콘 | ⚠ 제약 있음 | ✅ 자유로움 |
| 다크모드 색상 제어 | ✅ CSS fill | ✅ currentColor |
| TypeScript 자동완성 | ⚠ 문자열 기반 | ✅ 컴포넌트 타입 |
| SSR 호환성 | ⚠ 확인 필요 | ✅ 문제 없음 |
결론: 아이콘 200개 미만은 인라인 컴포넌트 + 코드 분할, 그 이상이면 SVG 스프라이트를 검토한다.
2. 통합 Icon 컴포넌트 설계
개별 아이콘 컴포넌트를 직접 노출하는 대신, 단일 Icon 컴포넌트로 추상화하면 나중에 내부 구현을 교체해도 사용처 코드가 바뀌지 않는다.
// components/Icon/Icon.tsx
import { lazy, Suspense, SVGProps } from 'react';
const iconComponents = {
'arrow-right': lazy(() => import('./icons/ArrowRight')),
'check': lazy(() => import('./icons/Check')),
'close': lazy(() => import('./icons/Close')),
// ...
} as const;
type IconName = keyof typeof iconComponents;
interface IconProps extends SVGProps<SVGSVGElement> {
name: IconName;
size?: number | string;
label?: string; // 접근성: 설명 텍스트
}
export function Icon({ name, size = 24, label, className, ...props }: IconProps) {
const Component = iconComponents[name];
return (
<Suspense fallback={<span style={{ width: size, height: size, display: 'inline-block' }} />}>
<Component
width={size}
height={size}
className={className}
aria-hidden={!label}
aria-label={label}
role={label ? 'img' : undefined}
{...props}
/>
</Suspense>
);
}
사용:
// 접근성 레이블 없음 (장식용)
<Icon name="arrow-right" size={16} className="text-gray-500" />
// 접근성 레이블 있음 (의미 있는 아이콘)
<Icon name="close" size={20} label="닫기" />
3. 색상을 디자인 토큰으로 제어
아이콘에 하드코딩된 색상을 없애고 currentColor + CSS 토큰으로 제어한다.
SVG 파일 준비 (SVGO 설정)
// svgo.config.js
module.exports = {
plugins: [
{ name: 'removeAttrs', params: { attrs: ['fill', 'stroke'] } },
'removeViewBox',
'removeDimensions',
],
};
fill 과 stroke 속성을 제거하면 CSS color → currentColor 로 제어된다.
토큰 기반 아이콘 색상
/* tokens.css */
:root {
--color-icon-primary: var(--color-gray-900);
--color-icon-secondary: var(--color-gray-500);
--color-icon-brand: var(--color-blue-600);
--color-icon-danger: var(--color-red-600);
--color-icon-disabled: var(--color-gray-300);
}
[data-theme="dark"] {
--color-icon-primary: var(--color-gray-100);
--color-icon-secondary: var(--color-gray-400);
--color-icon-brand: var(--color-blue-400);
--color-icon-danger: var(--color-red-400);
--color-icon-disabled: var(--color-gray-600);
}
// 사용
<Icon name="check" className="text-[var(--color-icon-brand)]" />
다크모드 전환 시 CSS 변수 하나만 바뀌면 모든 아이콘 색상이 자동 변경된다.
3-1. 색상 토큰 적용 — 라이트·다크 모드 비교
4. Figma에서 아이콘 관리
Figma Variables로 아이콘 토큰 정의
Figma Variables를 사용해 아이콘 크기와 색상을 토큰으로 관리한다.
Variables 구조:
Icons/
Size/
xs: 12
sm: 16
md: 24
lg: 32
xl: 40
Color/
primary: {semantic.icon.primary}
secondary: {semantic.icon.secondary}
brand: {semantic.icon.brand}
Figma → 코드 자동화 파이프라인
Figma 아이콘 업데이트
│
▼
figma-export (CI 스크립트)
│ Figma REST API로 SVG export
▼
SVGO 최적화
│ fill/stroke 제거, viewBox 정규화
▼
@svgr/core 변환
│ SVG → React 컴포넌트
▼
타입 자동 생성 (IconName union type)
│
▼
PR 자동 생성 or 패키지 배포
// scripts/export-icons.mjs
import Figma from 'figma-js';
import { transform } from '@svgr/core';
import { optimize } from 'svgo';
import fs from 'fs/promises';
const client = Figma.Client({ personalAccessToken: process.env.FIGMA_TOKEN });
async function exportIcons() {
const file = await client.file(process.env.FIGMA_FILE_ID);
const iconFrame = findFrame(file.document, 'Icons');
const nodeIds = iconFrame.children.map(n => n.id);
const { data } = await client.fileImages(process.env.FIGMA_FILE_ID, {
ids: nodeIds,
format: 'svg',
});
for (const [nodeId, svgUrl] of Object.entries(data.images)) {
const svgContent = await fetch(svgUrl).then(r => r.text());
const optimized = optimize(svgContent, { plugins: ['removeAttrs'] });
const componentName = nodeIdToComponentName(nodeId);
const component = await transform(optimized.data, {
plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'],
template: svgrTemplate,
});
await fs.writeFile(`src/components/Icon/icons/${componentName}.tsx`, component);
}
await generateIconIndex();
}
5. 접근성 처리
아이콘 접근성에는 두 가지 케이스가 있다.
장식용 아이콘 (Decorative)
텍스트와 함께 표시되는 아이콘은 스크린 리더에서 무시해야 한다.
// 텍스트가 의미를 전달하므로 아이콘은 숨김
<button>
<Icon name="arrow-right" aria-hidden="true" />
다음
</button>
의미 있는 아이콘 (Meaningful)
아이콘 단독으로 의미를 전달할 때는 레이블이 필수다.
// 아이콘만으로 동작을 표현할 때
<button aria-label="닫기">
<Icon name="close" aria-hidden="true" />
</button>
// 또는 visually-hidden 텍스트
<button>
<Icon name="close" aria-hidden="true" />
<span className="sr-only">닫기</span>
</button>
포커스 가시성
아이콘 버튼에 포커스 링을 명확히 표시한다.
.icon-button:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
border-radius: 4px;
}
6. 빌드 자동화와 CI 통합
Figma 업데이트가 자동으로 코드에 반영되는 파이프라인을 만든다.
# .github/workflows/sync-icons.yml
name: Sync Icons from Figma
on:
schedule:
- cron: '0 9 * * 1' # 매주 월요일 오전 9시
workflow_dispatch:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Export icons from Figma
env:
FIGMA_TOKEN: ${{ secrets.FIGMA_TOKEN }}
FIGMA_FILE_ID: ${{ secrets.FIGMA_FILE_ID }}
run: node scripts/export-icons.mjs
- name: Check for changes
id: changes
run: |
git diff --quiet || echo "changed=true" >> $GITHUB_OUTPUT
- name: Create PR if icons changed
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v6
with:
title: 'chore: sync icons from Figma'
branch: 'chore/sync-icons-${{ github.run_number }}'
commit-message: 'chore: sync icons from Figma'
7. 스토리북으로 아이콘 카탈로그
// Icon.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Icon } from './Icon';
import { iconNames } from './iconNames';
const meta: Meta<typeof Icon> = {
component: Icon,
argTypes: {
name: { control: 'select', options: iconNames },
size: { control: 'number' },
},
};
export default meta;
// 전체 아이콘 갤러리
export const AllIcons: StoryObj = {
render: () => (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 16 }}>
{iconNames.map((name) => (
<div key={name} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
<Icon name={name} size={24} />
<span style={{ fontSize: 10, color: '#666' }}>{name}</span>
</div>
))}
</div>
),
};
스토리북 아이콘 카탈로그가 있으면 디자이너와 개발자가 같은 화면을 공유하며 아이콘 이름을 정렬할 수 있다.
마치며
좋은 아이콘 시스템은 세 가지 조건을 만족한다.
- 단일 소스: Figma가 아이콘의 원본이고, 코드는 자동으로 동기화된다.
- 토큰 기반 색상:
currentColor+ CSS 변수로 다크모드와 테마 전환이 자동 처리된다. - 접근성 내재화: 장식/의미 있는 아이콘을 컴포넌트 레벨에서 명확히 구분한다.
팀 규모에 맞게 인라인 컴포넌트 방식으로 시작하고, 아이콘이 200개를 넘는 시점에 SVG 스프라이트 전환을 검토하는 것이 현실적인 경로다.