아이콘 시스템 설계 — SVG 스프라이트·컴포넌트·Figma 토큰 연동 전략

디자인 시스템

아이콘 시스템SVG 스프라이트Figma디자인 토큰접근성

이 글은 누구를 위한 것인가

  • 프로젝트마다 아이콘 파일을 개별로 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 를 활용해 CSS color 만으로 아이콘 색상 제어 가능
  • 트리 쉐이킹으로 사용하는 아이콘만 번들에 포함
  • 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',
  ],
};

fillstroke 속성을 제거하면 CSS colorcurrentColor 로 제어된다.

토큰 기반 아이콘 색상

/* 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. 색상 토큰 적용 — 라이트·다크 모드 비교

currentColor + CSS 토큰 다크모드 제어

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 to Code 아이콘 자동화 파이프라인

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>
  ),
};

스토리북 아이콘 카탈로그가 있으면 디자이너와 개발자가 같은 화면을 공유하며 아이콘 이름을 정렬할 수 있다.


마치며

좋은 아이콘 시스템은 세 가지 조건을 만족한다.

  1. 단일 소스: Figma가 아이콘의 원본이고, 코드는 자동으로 동기화된다.
  2. 토큰 기반 색상: currentColor + CSS 변수로 다크모드와 테마 전환이 자동 처리된다.
  3. 접근성 내재화: 장식/의미 있는 아이콘을 컴포넌트 레벨에서 명확히 구분한다.

팀 규모에 맞게 인라인 컴포넌트 방식으로 시작하고, 아이콘이 200개를 넘는 시점에 SVG 스프라이트 전환을 검토하는 것이 현실적인 경로다.