SVG 아이콘 vite 플러그인 vite-plugin-svgr으로 컴포넌트화

dobby·2025년 11월 25일
post-thumbnail

다른 팀의 코드를 리팩토링 중인데, svg 아이콘 하나하나를 파일로 관리해서 불필요한 import가 늘어나고 icon 사용이 효율적이지 않았다.

그래서 이를 개선했던 과정을 담아봤다.


SVG 아이콘 통합

svg sprite와 vite svg 컴포넌트 중 어떤걸 사용할까 고민하다, 일단 비교를 해봤다.

1. svg sprite

HTTP 요청 수가 1개(sprite 파일)이며 이후엔 캐싱되기 때문에 네트워크 요청이 줄어든다는 점이 장점이다.

하지만 파일 크기가 커질 수록 느려지기 때문에 성능 이슈가 발생할 수 있다.
또한 스타일링이 fillstroke 로 직접 svg로 제어하거나, currentColor 로 설정해두고 tailwind로 수정이 가능하다.

2. vite svg 컴포넌트화

svg 파일을 import 해서 컴포넌트처럼 사용할 수 있지만, 플러그인에 의존한다.

HTTP 요청 수는 0개(번들에 포함돼서)이며 코드 분할이 가능하다는 장점이 있다. 또한 초기 로드시 필요한 아이콘만 로드하기 때문에 빠르다.

컴포넌트처럼 사용되기 때문에, 스타일링도 props나 css-in-js 등으로 직접 제어할 수 있다.


아이콘 통합 방법 결정

비교는 해봤지만, 사실 아이콘이 18개 정도이고 더 추가될 가능성이 없기 때문에 성능상 두 방법은 차이가 없다고 봐도 된다.

스타일링도 코드를 보면 딱히 해주고 있는게 없고 img 태그로 넣어주기만 해서 굳이 필요 없다.

그래서 어떻게 아이콘들을 불러오는지를 확인해봤다.

// memo/utils/icons.ts
import bin from "@/assets/bin.svg";
import post from "@/assets/post.svg";
import search from "@/assets/search.svg";
import bulb from "@/assets/bulb.svg";
import onoff from "@/assets/onoff.svg";

import bold from "@/assets/bold.svg";
import italic from "@/assets/italic.svg";
import strike from "@/assets/strike.svg";
import code from "@/assets/code.svg";
import h1 from "@/assets/h1.svg";
import h2 from "@/assets/h2.svg";
import h3 from "@/assets/h3.svg";
import bulletlist from "@/assets/bulletlist.svg";
import numberlist from "@/assets/numberlist.svg";

export const icons = {
  bin,
  post,
  search,
  bulb,
  onoff,
};

export const mark = {
  bold,
  italic,
  strike,
  code,
  h1,
  h2,
  h3,
  bulletlist,
  numberlist,
};

메모장에서 사용하는 아이콘들을 한 파일로 묶어 export해 사용하고 있었다.

이 방식이 vite svg 컴포넌트와 비슷해서 코드를 최대한 많이 안건들기 위해 vite 플러그인을 활용하기로 결정했다.


vite-plugin-svgr 적용하기

  1. vite 플러그인 사용을 위해 vite-plugin-svgr 을 install 해준다.
  2. vite.config.ts 파일에 plugins로 다음을 추가해준다.
plugins: [
    svgr({
      svgrOptions: {
        icon: true,
      },
    }),
    ...
  1. 타입스크립트가 vite-plugin-svgr 이 제공하는 타입을 인식하도록 다음을 추가해준다.
// vite-env.d.ts
/// <reference types="vite-plugin-svgr/client" />

vite-plugin-svgrimport Logo from './logo.svg?react' 같은 식으로 svg를 react 컴포넌트로 로딩할 수 있게 해준다.
그런데 타입스크립트는 *.svg?react 형식을 기본적으로 모르기 때문에 컴파일 에러를 낸다.

그래서 vite-plugin-svgr 은 자기 타입 선언을 제공하고 있는데, 그게 바로 vite-plugin-svgr/client 다.

이 타입을 참조하면 타입스크립트가 *.svg?react 는 리액트 컴포넌트라는걸 알게 된다.

먼저, 현재 svg 파일을 그대로 import 하고 있는데, vite svg 컴포넌트로 사용할 때 기존 코드처럼 import하면 createElement("data:image/svg+xml,...") 으로 컴포넌트를 생성하려고 해 에러를 내뿜는다.
경로 뒤에 ?react 를 붙여줘야 한다.

svg 파일을 그대로 import 하게 되면 문자열 경로로 import 된다.

Uncaught InvalidCharacterError: Failed to execute 'createElement' on 'Document': The tag name provided ('data:image/svg+xml,%3c?

그냥 붙이면 해당 경로(?react)는 존재하지 않다고 파일 내에서 에러를 내뿜기 때문에, 타입 선언을 해준다.

// svg.d.ts
declare module "*.svg?react" {
  import * as React from "react";
  const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
  export default ReactComponent;
}

그리고 타입스크립트가 이 타입을 해석할 수 있도록 tsconfig 파일도 수정해준다.

// tsconfig.app.json
"include": ["src", "svg.d.ts"]

그리고 경로를 수정해 기존 방법과 똑같이 객체로 아이콘을 모아준다.

// components/common/icon/iconMap.ts
import BinIcon from "@/assets/bin.svg?react";
...

export const IconMap = {
  leftLine: LeftLineIcon,
  power: PowerIcon,
  rightLine: RightLineIcon,
  setting: SettingIcon,
  system: SystemIcon,
  ...
}

export type IconName = keyof typeof IconMap;

이 아이콘을 편하게 사용하기 위해 아이콘 컴포넌트를 만들어줬다.

// components/common/icon/icon.tsx
const Icon = ({ name, size = 24, className = "" }: IconProps) => {
  const IconComponent = IconMap[name];

  return (
    <IconComponent
      width={size}
      height={size}
      className={cn("inline-block", className)}
    />
  );
};

사용할 때는 아래처럼 사용해주면 된다.

<Icon name="bin" size={17} />

기존 코드와 비교

// 수정 전
import { icons } from "./utils/icons";

<img src={icons.bin} alt="delete" width={17} height={17} />

수정 전: 자산(Asset)으로 처리 (다수 HTTP 요청)

작동 방식: 수정 전 코드는 import bin from "@/assets/bin.svg";와 같이 SVG 파일을 불러왔을 때, 번들러가 이를 자산(Asset) 파일로 간주하고 해당 파일의 경로(URL 문자열)를 JavaScript 변수에 할당.

네트워크 결과: 이 경로 문자열을 React 컴포넌트가 <img> 태그 등으로 사용하면, 브라우저는 해당 URL로 새로운 HTTP 요청을 보내 SVG 파일을 다운로드.
아이콘이 18개면 18개의 HTTP 요청이 네트워크 탭에 하나하나 찍혔던 것.

하나의 객체로 export하고 여러 파일에서 이 객체를 import하여 사용하기 때문에, 객체를 import하는 모든 파일에서 사용 여부와 관계없이 객체에 담긴 모든 아이콘이 함께 로드된다.

// 수정 후
import Icon from "@/components/common/icon/icon";

<Icon name="bin" size={17} />

수정 후: 컴포넌트(Component)로 처리 (단일 JS 번들)

작동 방식: vite-plugin-svgr 플러그인과 ?react 또는 { ReactComponent } 임포트 구문을 사용하면서 모든 SVG 파일은 더 이상 단순한 자산이 아님.

플러그인은 SVG 파일의 XML 내용을 읽어 들임.

이 내용을 <svg>...</svg>를 반환하는 React 컴포넌트 함수로 변환.

이 변환된 JavaScript 코드가 메인 애플리케이션의 JavaScript 번들 파일 안에 통째로 삽입(Inlining)됨.

네트워크 결과: 브라우저는 여전히 JavaScript 번들 파일(예: index.js 또는 app.js)을 다운로드하지만, 그 안에 이미 18개 아이콘의 코드가 포함되어 있음.
따라서 별도로 SVG 파일을 다운로드하기 위한 HTTP 요청이 발생하지 않음.

IconMap.ts에 모든 아이콘이 묶여 있지만, 이 맵은 오직 Icon.tsx 컴포넌트 한 곳에서만 참조된다. 따라서 Icon.tsx 파일을 사용하지 않는 다른 모듈이나 컴포넌트들은 번들링 시 아이콘 코드 전체가 포함되지 않는다.


참고 자료

https://velog.io/@mari/React-Vite-svgr-setup
https://breathof.tistory.com/311
https://www.npmjs.com/package/vite-plugin-svgr

profile
성장통을 겪고 있습니다.

0개의 댓글