다른 팀의 코드를 리팩토링 중인데, svg 아이콘 하나하나를 파일로 관리해서 불필요한 import가 늘어나고 icon 사용이 효율적이지 않았다.
그래서 이를 개선했던 과정을 담아봤다.
svg sprite와 vite svg 컴포넌트 중 어떤걸 사용할까 고민하다, 일단 비교를 해봤다.
HTTP 요청 수가 1개(sprite 파일)이며 이후엔 캐싱되기 때문에 네트워크 요청이 줄어든다는 점이 장점이다.
하지만 파일 크기가 커질 수록 느려지기 때문에 성능 이슈가 발생할 수 있다.
또한 스타일링이 fill 과 stroke 로 직접 svg로 제어하거나, currentColor 로 설정해두고 tailwind로 수정이 가능하다.
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 을 install 해준다.vite.config.ts 파일에 plugins로 다음을 추가해준다.plugins: [
svgr({
svgrOptions: {
icon: true,
},
}),
...
vite-plugin-svgr 이 제공하는 타입을 인식하도록 다음을 추가해준다.// vite-env.d.ts
/// <reference types="vite-plugin-svgr/client" />
vite-plugin-svgr은import 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