안녕하세요 사내에서 아이콘을 Sprite icon으로 적용하기 위해 고민했던 부분을 공유하고자 합니다.
기존 상황
처음에는 tailwind 기반의 heroicons 아이콘을 사용하고 있었습니다, MVP를 빠르게 개발해야 하는 상황이었기에 heroicons가 가장 빠르고 쉽게 쓸 수 있어서 사용하고 있었고 MVP 개발 중에 heroicons 외의 커스텀 아이콘 적용이 필요한 상황에서는 SVG를 JSX로 사용하고 있는 상태였습니다.
MVP 기간 이후에 급하게 작업했던 부분들에 대해 수정이 필요하다 느꼈고 그중 아이콘 사용 방식은 깊은 고민 없이 사용하고 적용하고 있었기에 개발 생산성, 성능 최적화를 고려해야 하고 아직 서비스가 초기라서 실제 다른 서비스에 비해 아이콘을 사용하는 개수가 많지 않지만 점차 늘어난다면 마이그레이션을 하기가 쉽지 않다고 생각했기 때문에 마이그레이션을 진행하게 되었습니다.
heroicons에 의존적인 공용 아이콘 컴포넌트를 작성해서 사용하다 보니 디자이너 분이 만들어주신 커스텀 아이콘을 적용하기가 까다로웠습니다. 시간이 지날수록 아이콘을 사용하는 곳은 많아질 테니 이번 기회에 빠르게 마이그레션을 해야겠다고 결심했습니다.
앞으로 어떤 방식으로 사용해야 할까?
여러 가지 검색을 해봐서 정리했던 방법 중에는 크게 3가지였습니다.
- SVG 파일을 .tsx 파일로 변환하여 제어 및 사용
- icommon을 이용한 SVG 파일 웹폰트 변환 사용
- SVG Sprite 방식
SVG 파일을 .tsx 파일로 사용
번들러의 로더 등을 이용하여(webpack의 svgr 등) SVG를 사용한다면 직접 제어를 하거나 개발자가 커스텀이 가능한 장점이 있지만 커스텀이 가능하다는 것은 그만큼 개발자의 관리 포인트가 늘어난다는 단점과 함께 많아지는 SVG 파일을 관리하기 어렵고, Javascript 번들 크기가 커진다는 큰 단점이 존재했습니다.
위의 이미지는 JSX 커스텀 아이콘을 적용했을 때의 파일 리스트입니다.. 왼쪽에 보이는 모든 파일들이 각각의 SVG 아이콘 컴포넌트로 파일이 너무 많아지고 관리하기 어렵다는 단점을 매우 빠르게 체감하였습니다.
Icomoon을 이용한 SVG 파일 웹폰트 변환 사용
먼저 icomoon 서비스를 이용해서 SVG를 적용하는 방법은 SVG 파일을 웹폰트로 변환하여 결과로 받은 JSON을 이용해 쉽게 아이콘을 사용하고 적용할 수 있는 방법입니다. 아래는 icomoon 라이브러리를 이용하여 간단한 아이콘 컴포넌트 코드입니다.
1import IcoMoon, { IconProps } from "react-icomoon"; 2 3import { IconNames } from "./testConfig"; 4import iconSet from "../../public/selection.json"; 5 6interface ITestIcon extends IconProps { 7 icon: IconNames; 8} 9 10const TestIcon = (props: ITestIcon) => <IcoMoon iconSet={iconSet} {...props} />; 11 12export default TestIcon;
위 코드에서 import한 selection.json 파일은 icomoon 서비스를 통해 SVG를 웹폰트로 변환한 정보이며 위의 코드를 실제 사용하는 컴포넌트에서는 아래와 같이 사용할 수 있습니다.
1import TestIcon from "../TestIcon"; 2 3function TestComponent() { 4 return <TestIcon icon="account" size={20} color="orange" />; 5}
위와 같이 웹폰트로 변환하여 사용하면 커스텀 아이콘도 빠르게 적용해서 사용할 수 있는 장점이 존재합니다. 예전에 사용했던 경험이 좋아 다시 한번 고민해 본 방법 중 하나입니다. 다만 아래와 같은 단점이 존재합니다.
- 외부 서비스에 의존적
- icomoon을 이용하여 변환을 해야 하므로 외부 서비스의 의존도가 매우 높습니다.
- 폰트 방식의 문제점
- 글꼴 파일을 불러와 빌드하는 시간이 소요
- 웹 접근성이 좋지 않음
- 화면 확대 시 화질 저하 발생
SVG Sprite
마지막 방법은 이미지 스프라이트 원리와 동일한 SVG 스프라이트 방식을 이용해서 SVG를 최적화하여 사용할 수 있는 기법으로 위에서 사용한 방법들의 단점들을 극복할 수 있으며 아래와 같은 장점이 있습니다.
- 모든 그래픽 요소(아이콘)가 하나의 파일에 있어 관리가 용이
- 페이지에서 아이콘 시트를 한 번만 요청해서 전달받은 후 필요한 부분만 뽑아서 사용하므로 불필요한 요청이 없음
- inline svg로 할 수 있는 작업(icon의 width, height, color 등)이 가능
- 외부 자산으로 Javascript 번들에 포함되지 않음
마이그레이션 시작!
위의 방법 중 SVG Sprite 방법이 관리가 용이하며 번들에 포함되지 않는 부분이 가장 매력적으로 다가왔습니다. 그래서 기존 heroicons에서 SVG Sprite 아이콘으로 마이그레이션을 진행하였으며 아래와 같은 순서로 진행하였습니다.
SVG Sprite 아이콘 컴포넌트 및 SVG 적용 → 기존 아이콘 컴포넌트로 사용하고 있는 부분을 각 페이지 별로 수정 후 테스트(반복) → 기존 아이콘 컴포넌트 제거
SVG Sprite 아이콘을 적용하기 위해서는 한 가지 작업이 더 필요했습니다. 바로 SVG 파일을 Sprite 형태로 변화하는 작업이 필요했습니다. SVG Sprite 아이콘 형태 변환하면 일반적으로 아래와 같이 svg태그와 symbol 태그로 감싸져있는 형태로 구성이 됩니다.
1<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0"> 2 <symbol viewBox="0 0 24 24" id="account" xmlns="http://www.w3.org/2000/svg"> 3 <path 4 d="M14.999 19.128a9.379 9.379 0 0 0 2.625.372 9.336 9.336 0 0 0 4.121-.952 4.124 4.124 0 0 0-7.533-2.493m.787 3.073v-.003c0-1.113-.286-2.16-.787-3.07m.787 3.073v.106A12.318 12.318 0 0 1 8.623 21c-2.33 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07m-2.213-9.68a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" 5 stroke="fill" 6 strokeWidth="1.5" 7 strokeLinecap="round" 8 strokeLinejoin="round" 9 /> 10 </symbol> 11</svg>
즉 여러 개의 svg 코드를 각각 하나의 symbol 태그로 감싸고 전체를 하나의 svg 태그로 감싸주도록 형태를 변환해 주는 작업이 필요합니다. 구글에서 검색해 보면 Spritebot을 이용해서 쉽게 변환을 하는 경우가 많았지만 회사 내 보안 규정이 있어 외부 응용프로그램을 쉽게 사용할 수 없는 환경이기 때문에 직접 변환을 하는 스크립트를 작성하였습니다. 스크립트를 팀원이 조금 더 편하게 사용할 수 있도록 추가 작업이나 코드 수정이 필요하지만 우선 핵심 기능인 변환 코드는 아래와 같습니다.
1const fs = require("fs"); 2const path = require("path"); 3const SVGSpriter = require("svg-sprite"); 4 5const config = { 6 mode: { 7 symbol: { 8 inline: true, 9 }, 10 }, 11}; 12 13const transferSvgToSprite = async () => { 14 const spriter = new SVGSpriter(config); 15 const folderPath = "./icons"; 16 const files = fs.readdirSync(folderPath); 17 18 files.forEach((file) => { 19 const filePath = `./${path.join(folderPath, file)}`; 20 spriter.add(filePath, null, fs.readFileSync(filePath, "utf-8")); 21 }); 22 23 console.log("File Load 성공! SVG Sprite 변환이 시작됩니다."); 24 console.log( 25 "-----------------------------------------------------------------" 26 ); 27 spriter.compile((error, result) => { 28 for (const mode in result) { 29 for (const resource in result[mode]) { 30 fs.mkdirSync(path.dirname(result[mode][resource].path), { 31 recursive: true, 32 }); 33 fs.writeFileSync( 34 result[mode][resource].path, 35 result[mode][resource].contents 36 ); 37 } 38 } 39 }); 40 41 console.log("SVG Sprite 변환 완료! 파일이 생싱 또는 수정되었습니다."); 42 43 const svgCode = await fs.promises.readFile( 44 "./symbol/svg/sprite.symbol.svg", 45 "utf-8" 46 ); 47 const DOMParser = require("xmldom").DOMParser; 48 49 console.log( 50 "-----------------------------------------------------------------" 51 ); 52 console.log("svg sprite json 파일 수정을 시작합니다."); 53 const xmlDoc = new DOMParser().parseFromString(svgCode, "text/xml"); 54 55 const svgElement = xmlDoc.getElementsByTagName("svg")[0]; 56 const symbolElements = xmlDoc.getElementsByTagName("symbol"); 57 58 svgElement.setAttribute("xmlns", "http://www.w3.org/2000/svg"); 59 svgElement.removeAttribute("width"); 60 svgElement.removeAttribute("height"); 61 svgElement.removeAttribute("style"); 62 63 Array.from(symbolElements).forEach((symbol) => { 64 symbol.removeAttribute("fill"); 65 }); 66 67 const modifiedSvgCode = xmlDoc.toString(); 68 69 fs.writeFileSync("./symbol/svg/sprite.symbol.svg", modifiedSvgCode, "utf-8"); 70 71 console.log( 72 "-----------------------------------------------------------------" 73 ); 74 console.log("svg sprite json 파일 수정을 완료하였습니다."); 75}; 76 77module.exports = { transferSvgToSprite };
SVG 파일을 읽어서 svg-sprite 라이브러리를 이용해 형태를 변환 후 xmldom 라이브러리로 직접 접근해서 불필요한 속성 및 필요한 속성을 추가하는 코드입니다. 설명의 편의성을 위해 변환된 결괏값을 sheet로 부르겠습니다.
sheet를 변수로 저장하고 sheet 내부의 각 아이콘 이름을 id로 받는 공용 아이콘 컴포넌트를 작성하였습니다. 아래의 코드처럼 svg 태그 안에 use 태그를 사용하고 #(아이콘 이름)을 id로 넘겨주면 sheet 내부의 동일한 이름의 아이콘이 매칭되어 사용할 수 있게 됩니다.
1export interface IIconProps extends HTMLAttributes<SVGSVGElement> { 2 size?: keyof typeof IconSizeEnum; 3 color?: ThemeColorType; 4 strokeColor?: ThemeColorType; 5 stopPropagation?: boolean; 6 id: FileNamesType; 7 cursor?: CursorType; 8} 9 10export function SpriteIcon({ 11 size = "lg", 12 color = "black", 13 id, 14 strokeColor, 15 stopPropagation, 16 cursor, 17 onClick, 18 ...props 19}: IIconProps) { 20 return ( 21 <svg 22 width={IconSizeEnum[size]} 23 height={IconSizeEnum[size]} 24 fill={theme.colors[color]} 25 style={{ cursor, minWidth: IconSizeEnum[size], ...props.style }} 26 {...props} 27 onClick={(e) => { 28 onClick && onClick(e); 29 stopPropagation && e.stopPropagation(); 30 }} 31 > 32 <use href={`#${id}`} stroke={strokeColor && theme.colors[strokeColor]} /> 33 </svg> 34 ); 35}
svg를 sprite로 변환하고 사용할 수 있는 공용 컴포넌트까지 만들었습니다. 다른 컴포넌트에서 적용을 할 때 매번 sheet를 서버에 요청하는 것보다는 요청 횟수를 줄이는 것이 효율적이기 때문에 portal을 이용해서 body 태그 내에 sheet 자체를 삽입하고 이후에 추가로 요청하지 않고 삽입한 부분에서 꺼내서 사용하는 방식으로 호출을 최소화하였습니다.
1export function GlobalSvgProvider() { 2 if (typeof window === "undefined") return <></>; 3 return createPortal(spriteCode, document.body); 4} 5 6const App = ({ Component, pageProps }: AppProps) => { 7 return ( 8 <QueryProvider client={queryClient}> 9 <UiProvider> 10 <MSWProvider> 11 <Component {...pageProps} /> 12 <ModalPortal /> 13 <ToastContainer /> 14 <GlobalSvgProvider /> 15 </MSWProvider> 16 </UiProvider> 17 </QueryProvider> 18 ); 19};
이제는 실제로 아이콘을 사용하는 페이지 또는 컴포넌트에 적용만 하면 끝입니다. 아래는 적용한 코드의 예시입니다!
1<SpriteIcon 2 cursor="pointer" 3 id="xMark" 4 onClick={onClose} 5 color="transparent" 6 strokeColor="black" 7 size="lg" 8/>
이렇게 아이콘을 적용한 후 크롬의 Lighthouse를 이용해 Treemap 항목의 번들링된 파일의 크기와, 그 중 사용하지 않는 파일의 감소량을 비교해봤습니다.
위의 이미지 변화처럼 전체 번들 사이즈는 약 3.43% 감소하였으며, 사용하지 않는 번들의 크기도 약 7.91% 감소했습니다. 서비스 개발 초기 단계임에도 불구하고, 전체 번들 크기가 예상보다 크게 줄어들었고, 사용되지 않는 번들 크기의 감소율도 상당히 높아져 성능을 크게 향상하였습니다.
개선할 점
- Sprite 변환 스크립트 개선
- 아이콘을 추가할 때 매번 스크립트가 존재하는 다른 폴더(다른 Repository)에서 아이콘을 추가하고 스크립트를 실행해야 하는 불편함이 존재합니다.
- Electron을 이용해 프로그램 단위로 실행을 해서 직관적으로 아이콘을 변환하는 방법과 서비스 프로젝트가 모노레포이기 때문에 모노레포 내의 패키지로 구성하여 사용할 수 있게 하는 방법등을 고민하고 있으며 불편함을 해소시킬 수 있을 것으로 기대합니다.
- 번들 사이즈 최적화
- 아이콘을 최적화 하면서 번들사이즈를 줄일 수 있었지만 그럼에도 불필요한(사용하지 않는) 번들이 많이 존재하는 것 같아 코드 스플리팅, 라이브러리 분석을 이용하여 번들 사이즈를 조금 더 줄일 수 있을 것 으로 기대합니다.
마무리
아이콘은 웹 서비스에서 기본 적인 자산(asset)이며 정말 많은 곳에서 사용하기 때문에 서비스를 사용하는 사용자의 경험뿐만이 아닌 개발자들의 개발 경험도 매우 중요하다고 생각했습니다. 그러므로 이번 아이콘 최적화 또한 개발자의 경험을 중요시 하였고 서비스가 커짐에 따라 마이그레이션이 어렵기 때문에 가장 적절한 시기에 최적화 및 마이그레이션을 진행할 수 있어서 다행이라는 생각도 하였습니다.
감사합니다.