어느 날, 디자이너의 요청이 있었습니다.
아이콘 라이브러리 heroicons에서 RemixIcon으로 변경해도 괜찮을까요?
기존 heroicons가 디자인적으로 아쉬운 점이 있었고, RemixIcon이 종류가 10배나 더 많아요!
처음에는 '디자이너가 그렇게 생각한다면 변경해야지'라는 1차원적인 생각을 했다가,
문득 10배
라는 단어에 꽂혔습니다.
지금 라이브러리에서 전부 불러오는 형태인데, 그러면 라이브러리 크기도 10배가 되는 거 아닌가?
그렇담 번들 사이즈도 아이콘때문에 커지지 않을까?
라는 호기심에서 시작되어 아이콘 세팅으로 번들 사이즈를 약 80% 줄여버린 이야기, 시작합니다!
웹 상에서 아이콘을 관리하는 방법은 다양합니다. 필자가 지금껏 시도해 본 방법은 아래 세 가지입니다.
이 외의 유용한 방법이 있다면 댓글로 남겨주시면 감사하겠습니다!
소수의 아이콘만을 사용할 때 주로 쓰는 방법이자 가장 공수가 적게 드는 방법입니다. 일반적인 패키지를 설치하듯이, 아이콘 패키지 전체를 설치하여 필요한 아이콘을 import해서 쓰는 방식입니다.
대표적인 아이콘 라이브러리는 react-icons가 있습니다. 앞서 언급한, heroicons과 RemixIcon도 있습니다.
npm install react-icons
import { FaBeer } from 'react-icons/fa';
function SampleComponent () {
return <h3> Lets go for a <FaBeer />? </h3>
}
svg 기반의 아이콘 코드를 symbol 태그를 활용하여 직접 한 파일(IconLoader.tsx
)에 넣은 뒤, Icon 컴포넌트를 호출할 수 있도록 global하게 선언합니다. 일종의 Provider인 셈이죠. 그런 다음 Icon 컴포넌트에 props로 아이콘의 이름을 넣어 해당 아이콘을 호출하는 방식입니다.
참고: Simple icon systems using SVG sprites
IconLoader.tsx
export const IconLoader = () => (
<svg
id="sprite-symbols"
version="1.1"
xmlns="http://www.w3.org/2000/symbol"
xmlnsXlink="http://www.w3.org/1999/xlink"
style={{ display: "none" }}
>
<defs id="defs">
<symbol width="24" height="24" fill="none" id="forward">
<path
stroke="#7C644C"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M10.137 15.42c.148 1.72.363 2.58.363 2.58s2-.5 5.5-2.5 4.5-3.5 4.5-3.5-1-1.5-4.5-3.5S10.5 6 10.5 6s-.216.866-.364 2.597m.001 6.824A40.021 40.021 0 0 1 10 12c0-1.369.059-2.503.136-3.403m.001 6.824C8.392 16.43 6.477 17.506 4.5 18c0 0-.5-2-.5-6s.5-6 .5-6c1.903.476 4.054 1.47 5.636 2.597"
/>
</symbol>
<symbol id="share" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path
stroke="#3C5454"
strokeWidth="1.5"
d="m8.684 10.658 6.628-3.314m.004 9.314-6.622-3.311M21 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm12 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</symbol>
{/* 생략 */}
</defs>
</svg>
);
App.tsx
import { Navigate, Route, Routes } from "react-router-dom";
import "./App.css";
import { IconLoader } from "components/IconLoader";
function App() {
return (
<div className="App">
<IconLoader />
<Routes>
{/* 생략 */}
</Routes>
</div>
);
}
export default App;
Icon.tsx
#${name}
으로 특정 svg를 불러오는 것을 확인할 수 있습니다. interface IconProps {
name: IconName;
width: number;
height: number;
}
/**
* @example
* <Icon name="close" width={10} height={10} />
*/
const Icon = ({ name, width, height }: IconProps) => {
return (
<svg
style={{ pointerEvents: "none" }}
width={width.toString()}
height={height.toString()}
stroke="none"
>
<use href={`#${name}`} />
</svg>
);
};
export default Icon;
export type IconName =
| "forward"
| "share"
// 생략
TopButton.tsx
)import Icon from "components/Icon";
// 생략
interface TopButtonProps {
text: string;
onClick: () => void;
}
const TopButton = ({ text, onClick }: TopButtonProps) => {
return (
<>
<S_Button onClick={onClick}>
{text}
<Icon name="forward" width={24} height={24} />
</S_Button>
</>
);
};
// 생략
텍스트의 폰트 로드 방식처럼 아이콘을 로드하여 사용합니다. 웹 폰트로서 배포된 상태의 라이브러리에서 쓸 수 있습니다.
이 방식에 대한 자세한 내용은 후술하겠습니다.
후술할 아이콘 환경 설정 방식의 변경은 아래 맥락을 바탕으로 합니다.
현업에서 기존에는 1번 방식(아이콘 라이브러리)을 기반으로 한 Icon.tsx
를 패키지 형태로 만들어 쓰고 있었습니다.
🧑🎨: 디자이너
🧑💻: 개발자
이 방식을 염두에 두고 어떻게 해야 아이콘 라이브러리를 변경해도 번들 사이즈의 부담을 얻지 않을 수 있을지 고민했습니다.
시도했던 것을 나열하기 전, 번들 사이즈를 크기 비교를 위해 재연해보도록 하겠습니다.
아래 번들 사이즈를 분석하기 위해 @next/bundle-analyzer를 사용했습니다.
{
"package":{
"next": "14.2.14",
"react": "^18",
"react-dom": "^18",
}
}
예시 애플리케이션에서는 서버 컴포넌트 하나, 클라이언트 컴포넌트 하나 각각에 아이콘 100개를 넣어두었습니다.
ANALYZE=true npm run build
위 스크립트를 실행하면 세 가지 창을 통해 분석 결과가 뜨는데 그 중 아래의 항목만 살펴보도록 하겠습니다.
NodeJS-stat
서버 사이드에서 참조하는 전체 번들 크기를 의미합니다. 서버에 배포될 번들 크기이기 때문에 서버 메모리 사용에 직접적인 영향을 미칩니다.
Client-parsed
클라이언트 측에서 번들이 로드된 후 파싱되어 브라우저 메모리에 로드되는 크기를 의미합니다. 사용자가 애플리케이션을 로드하는 초기 시간에 영향을 주며, 작을수록 빠르게 처리됩니다.
Client-gzipped
서버에서 클라이언트로 전송될 때 압축된 번들 크기로, 네트워크 전송 시의 실제 크기를 의미합니다. 네트워크 대역폭과 로드 속도에 직접적인 영향을 미칩니다.
확실히 아이콘 갯수가 많은 라이브러리일수록 번들 사이즈가 커짐을 알 수 있습니다.
기존 Icon 컴포넌트에서 번들 사이즈에 가장 큰 영향을 끼친 부분은 아래 import 단입니다.
import * as RemixIcons from "@remixicon/react";
여기서 *(asterisk)가 RemixIcons가 가지고 있는 아이콘 전부를 가져오기 때문에 정작 쓰지 않는 것까지 불러오고 있는 상황입니다.
NextJS의 자체 기능 중, 사용하는 module만 가져가는 optimizePackageImports도 시도해보았으나 아무런 영향을 끼치지 않는 것을 확인했습니다. 아마 experimental이기도 하고, heroicons와 달리 RemixIcon은 기본적으로 지원하지 않는 라이브러리라 그런 것 같습니다.
그리하여 첫 번째 시도는
확실히 쓰는 아이콘만 가져와서 CLI를 돌린 다음, import 단을 최적화하자
에서 시작된 시도입니다.
최적화를 목표로 한다면 최우선은 용량이 적거나 자체 optimize를 해주는 icon library로 변경(tabler, heroicons)하는 것이 맞으나, 디자인 측의 세팅 롤백, 보일러플레이트 수정 등 공수를 고려하면 RemixIcon를 고수하는 것이 맞다고 판단했습니다.
webpack 설정에서 babel을 사용하는 방법도 찾았으나 NextJS의 자체 complier인 SWC와의 불필요한 충돌이 일어날까 싶어 CLI를 이용한 방법을 생각했습니다.
package.json
{
// 생략
"scripts": {
// 생략
"icon-scrape": "variable=$(grep -r 'name=\"Ri' ./src | sed -E 's/.*name=\"([^\"]+)\".*/\\1/' | sort | uniq | tr '\n' ', ') && variable=${variable%, } && echo $variable | pbcopy"
},
}
🖥️ script 상세 설명
grep -r 'name=\"Ri' ./src
: name="Ri"로 시작하는 Icon 컴포넌트를 찾습니다. 이 부분은 어떤 아이콘 패키지를 쓰느냐에 따라 충분히 수정될 수 있습니다. 하지만 현재는 RemixIcon의 특성상, 모든 아이콘들이 Ri-로 시작하기 때문에 Ri로 시작하는 아이콘 이름만 추출하도록 필터링합니다.
sed -E 's/.*name=\"([^\"]+)\".*/\\1/'
: sed를 사용하여 각 줄에서 name="..." 부분만 추출합니다. 이 정규식은 name="..." 사이의 텍스트를 추출하여 아이콘 이름만 남깁니다. \1은 괄호 안에 있는 첫 번째 캡처 그룹을 의미하고, 예를 들어 name="RiAccountBoxLine"라면 RiAccountBoxLine만 추출합니다.
sort | uniq
: 아이콘 이름을 정렬하고, 중복되는 아이콘 이름을 제거합니다. 정렬을 하는 이유는, 그저 보기 좋기 때문입니다.😅 찾는 것은 command+F로도 할 수 있잖아요!
tr '\n' ', '
: 아이콘 이름들을 쉼표로 구분된 하나의 문자열로 변환합니다.
variable=${variable%, }
: 결과의 가장 마지막 쉼표를 제거합니다.
echo $variable | pbcopy
: 결과를 클립보드에 복사합니다.
npm run icon-scrape
// command+v
RiAccountBoxLine,RiAccountCircleLine,RiAddBoxLine,RiAddCircleLine,RiAddLine,RiAlarmLine,RiAliensLine,RiAlignBottom,RiAlignCenter,RiAlignLeft,RiAlignRight,RiAlignTop,RiAnchorLine,RiArchiveLine,RiArrowDownCircleLine,RiArrowDownLine,RiArrowDownSLine,RiArrowLeftCircleLine,RiArrowLeftLine,RiArrowLeftSLine,RiArrowRightCircleLine,RiArrowRightLine,RiArrowRightSLine,RiArrowUpCircleLine,RiArrowUpLine,RiArrowUpSLine,RiAtLine,RiAwardLine,RiBankLine,RiBarChartBoxLine,RiBarChartLine,RiBattery2ChargeLine,RiBatteryChargeFill,RiBatteryLine,RiBatteryLowLine,RiBellLine,RiBillLine,RiBluetoothLine,RiBookLine,RiBookmarkLine,RiBriefcaseLine,RiBrushLine,RiBugLine,RiCakeLine,RiCalendarLine,RiCamera2Fill,RiCameraLine,RiChatAiLine,RiChatPrivateLine,RiChatVoiceAiFill,RiChatVoiceLine,RiCheckLine,RiCheckboxBlankCircleFill,RiCheckboxBlankLine,RiCheckboxCircleFill,RiCloseCircleLine,RiCloseLine,RiCloudLine,RiCloudWindyFill,RiCodeLine,RiCoinLine,RiCommandLine,RiCompassLine,RiContrastLine,RiCopyleftLine,RiCornerDownLeftFill,RiCursorLine,RiDashboardLine,RiDeleteBack2Fill,RiDeleteBin5Line,RiDeleteBinLine,RiDeleteColumn,RiDirectionFill,RiDonutChartFill,RiDownloadCloudLine,RiDownloadLine,RiDragDropLine,RiEditLine,RiEmotionUnhappyLine,RiErrorWarningLine,RiExchangeLine,RiExternalLinkLine,RiEyeCloseLine,RiEyeLine,RiFacebookLine,RiFileLine,RiFilmLine,RiFilterLine,RiFlagLine,RiFlashlightLine,RiFolderLine,RiFormatClear,RiFullscreenExitFill,RiGiftLine,RiGoogleLine,RiGroupLine,RiHardDriveLine,RiHeartLine,RiSendBackward,RiVipDiamondFill,
해당 스크립트를 실행하면 app 내에서 쓰이는 icon name을 전부 가져와 클립보드에 옮기는 형태입니다.
복사한 것을 바탕으로 Icon 컴포넌트도 아래와 같이 코드를 변경해야 합니다.
// 👇 1. import 변경
import {
RiAccountBoxLine,
RiAccountCircleLine,
RiAddBoxLine,
RiAddCircleLine,
RiAddLine,
// 생략
} from "@remixicon/react";
import { cn } from "@/util/cn";
import { SVGProps } from "react";
type AllSVGProps = SVGProps<SVGSVGElement>;
type ReservedProps = "color" | "size" | "width" | "height" | "fill" | "viewBox";
interface RemixiconProps
extends Pick<AllSVGProps, Exclude<keyof AllSVGProps, ReservedProps>> {
color?: string;
size?: number | string;
children?: never;
}
// 👇 2. iconMap 추가
const iconMap = {
RiAccountBoxLine,
RiAccountCircleLine,
RiAddBoxLine,
RiAddCircleLine,
RiAddLine,
// 생략
};
// 👇 3. type 변경
type RemixIconsType = keyof typeof iconMap;
export interface IconProps extends RemixiconProps {
name: RemixIconsType;
}
function Icon({ name, className, ...props }: IconProps) {
// 👇 4. RemixIcon 호출부 변경
const RemixIcon = iconMap[name];
return <RemixIcon className={cn("shrink-0", className)} {...props} />;
}
export { Icon };
클라이언트 사이드 쪽의 번들 사이즈는 유의미하게 감소한 것을 알 수 있습니다.
Node-stat
: 5.11 MB -> 5.12 MB
+0.2%
)Client-parsed
: 2.73 MB -> 624.83 KB
-77.1%
)Client-gzipped
: 602.53 KB -> 183.35 KB
-69.6%
)실제 현업에서 적용할 때는 사용하는 아이콘 수가 18개였고, 실제 애플리케이션을 두고 번들 사이즈 테스트를 한 결과 총 98.5%가 감소된 것을 알 수 있었습니다.
서버 컴포넌트, 클라이언트 컴포넌트가 어떻게, 얼마만큼 있는지 또한 사이즈 감소에 영향을 끼치는 것을 파악할 수 있습니다.
그런데 이 방식의 단점이 있었습니다.
만약 프로젝트 별 사용하는 아이콘이 미리 정해져 있고 디자인 단에서(figma 상) 리스트업이 되어 있는 상태라면, 이 작업을 프로젝트 초기에 실행하여 미리 세팅할 수 있지만, 이 작업을 미리 할 수 없다면, 프로젝트 후반부에 수동으로 실행해야하는 불편함이 있었습니다.
비록 그것이 애플리케이션의 최적화를 위해서라도 개발하기도 바쁜데 부가적인 단계를 추가하는 것은 개발자 경험을 떨어뜨리는 것이 분명했습니다. 아이콘을 scrape하는 것은 CLI를 통한 자동화지만, 결국 아이콘 컴포넌트 코드를 변경해야하는 것은 수동이기에 오히려 불편한 자동화를 만들었다고 생각했습니다.
그리하여
개발자가 별도로 수동 작업을 하지 않고 번들 사이즈를 줄이는 방법은 없을까
에서 시작된 두 번째 시도를 소개하겠습니다.
RemixIcon의 README.md를 살펴보면 Basic Usage 아래 Webfont Usage가 있습니다.
Webfont는 말 그대로 웹 상의 폰트입니다. 그런데 아이콘으로서의 Webfont는 일반적인 텍스트나 숫자로 이루어진 것이 아니라 아이콘으로 만들어진 폰트이고, 아이콘 폰트와 css가 className으로 한 쌍을 이루어 보여주는 것이 아이콘 Webfont의 원리입니다.
css와 font 파일은 svg보다 비교적 용량이 작고 자체적으로 서브셋으로 나눌 수 있는 특징을 활용해 필요한 아이콘만 로드되도록 만들 수 있을 것이라 생각되어, Webfont로 한 번 아이콘을 세팅해 보았습니다.
폰트 파일 다운로드
폰트 세팅과 layout.tsx에서 import
icon 컴포넌트 코드 변경
import { cn } from "@/util/cn";
interface IconProps {
name: string;
className?: string;
}
function Icon({ name, size, className }: IconProps) {
return (
<i className={cn(`ri-${name}`, className)}></i>
);
}
export { Icon };
기존 svg를 이용하는 아이콘보다 훨씬 간결해졌습니다. 하지만 라이브러리를 쓸 때 가져올 수 있었던 아이콘 name의 타입을 이제는 가져올 수 없기에 이때 조금이나마 개발자 경험을 향상시키기 위해 트릭을 썼습니다.
remixicon.less
에는 전체 아이콘 이름이자 폰트의 className이 있습니다. 이 파일을 통째로 갖다가 다음의 파싱 함수를 작성하여 enum type 형태의 값을 뽑아냈습니다.
const getTypesFrom = (str) => {
return str.split(':global {')[1].split('\n').filter(ele => ele !== '}' && ele !== '').map(ele => ele.split(':before')[0].replace('ri-', '').replace('.', '')).join('"|"')
}
"24-hours-fill"|"24-hours-line"|"4k-fill"|"4k-line"|"a-b"|"account-box-fill"|"account-box-line"|"account-circle-fill"|"account-circle-line"|"account-pin-box-fill"|"account-pin-box-line"|"account-pin-circle-fill"| //...
이 값을 이용하여 RemixType을 새롭게 정의했습니다.
import { cn } from "@/util/cn";
import { RemixType } from "./remix-type";
interface IconProps {
name: RemixType;
className?: string;
}
function Icon({ name, size, className }: IconProps) {
return (
<i className={cn(`ri-${name}`, className)}></i>
);
}
export { Icon };
Node-stat
: 5.11 MB -> 884.23 KB
-82.7%
)Client-parsed
: 2.73 MB -> 565.98 KB
-79.3%
)Client-gzipped
: 602.53 KB -> 169.68 KB
-71.8%
)클라이언트 측은 1번 방식와 비슷한 감소량이지만 서버 측 리소스는 Webfont 방식이 확연한 감소량을 보여주었기 때문에 최종적으로 Webfont 방식을 채택하게 되었습니다.
반면 Webfont를 이용한 아이콘은 항상 i tag를 이용하기 때문에 자동 접근성을 지원하지 않는다는 단점이 있습니다. 따라서 아이콘마다 추가적인 접근성 처리가 필요합니다. 더불어 Webfont 자체를 지원하지 않는 아이콘 라이브러리라면 적용 자체가 힘들 것 같습니다.
다들 어떤 경우, 어떤 방식의 아이콘 환경 설정을 선택하셨나요?
저의 경우는 이렇습니다.
아이콘 라이브러리는 아래 두 가지 경우일 때 사용했습니다.
svg sprite는 보통 두 가지의 경우일 때 시도해 봤습니다.
Webfont의 경우 이번이 처음인데 추후 다음 경우가 발생한다면 도입을 고려할 것 같습니다.
이렇듯 각각의 장점과 단점이 존재하는데, 그래서 결국 사용하는 환경에 따라 적절한 것을 선택하면 되는 것입니다. 아이콘을 쓰는 것만큼은 은총알이 없는 것 같습니다.