[디자인 시스템] 최고의 아이콘 컴포넌트를 찾아서...

한낱·2024년 6월 26일
2

디자인 시스템

목록 보기
8/9

어느 멋진 날...

올해 초, CMC 동아리에서 한 달간 React Native 스터디를 진행했었다.
1주일에 하나의 소규모 프로젝트씩 총 4개를 만들어보며 React Native 사용법을 익히는 스터디였는데, 덕분에 이것 저것 실험적인 시도를 여럿 해볼 수 있었다.
그 중 가장 날 미치게했던 건 아이콘이었다.

1주차 아이콘은 모두 다 단일 path로 되어 있어서 path만 추출해 단순 객체로 데이터를 관리해보았다.

const svgPaths = {
    home: 'M5 19V10.308C5 10.052 5.05733 9.80966 5.172 9.58099C5.286 9.35232 5.444 9.16366 5.646 9.01499L11.031 4.93799C11.3123 4.72266 11.6343 4.61499 11.997 4.61499C12.359 4.61499 12.683 4.72266 12.969 4.93799L18.354 9.01499C18.556 9.16366 18.714 9.35232 18.828 9.58099C18.9427 9.80966 19 10.052 19 10.308V19C19 19.268 18.9003 19.5017 18.701 19.701C18.501 19.9003 18.2673 20 18 20H14.615C14.3863 20 14.1947 19.9227 14.04 19.768C13.8853 19.6133 13.808 19.4213 13.808 19.192V14.423C13.808 14.1943 13.7303 14.0027 13.575 13.848C13.4203 13.6927 13.2287 13.615 13 13.615H11C10.7713 13.615 10.5797 13.6927 10.425 13.848C10.2697 14.0027 10.192 14.1943 10.192 14.423V19.193C10.192 19.4217 10.1147 19.6133 9.96 19.768C9.80533 19.9227 9.61367 20 9.385 20H6C5.732 20 5.49833 19.9003 5.299 19.701C5.09967 19.501 5 19.2673 5 19Z',
    arrowBack: 'M8.82 12L16.535 19.715C16.6817 19.863 16.7543 20.0397 16.753 20.245C16.751 20.4497 16.6763 20.6257 16.529 20.773C16.3817 20.9203 16.2053 20.994 16 20.994C15.7947 20.994 15.6183 20.9207 15.471 20.774L7.83 13.136C7.66867 12.974 7.55067 12.794 7.476 12.596C7.40133 12.3967 7.364 12.1977 7.364 11.999C7.364 11.8003 7.40133 11.6017 7.476 11.403C7.55067 11.2043 7.66867 11.0243 7.83 10.863L15.47 3.21999C15.6173 3.07266 15.7947 2.99999 16.002 3.00199C16.2087 3.00399 16.386 3.07866 16.534 3.22599C16.682 3.37333 16.7553 3.54966 16.754 3.75499C16.754 3.95966 16.6807 4.13599 16.534 4.28399L8.82 12Z',
    trash: 'M11.5 15.308H12.5V10.619L14.6 12.708L15.308 12L12 8.69198L8.692 12L9.4 12.708L11.5 10.619V15.308ZM7.615 20C7.155 20 6.771 19.846 6.463 19.538C6.15433 19.2293 6 18.845 6 18.385V5.99998H5V4.99998H9V4.22998H15V4.99998H19V5.99998H18V18.385C18 18.845 17.846 19.229 17.538 19.537C17.2293 19.8456 16.845 20 16.385 20H7.615ZM17 5.99998H7V18.385C7 18.5383 7.064 18.6793 7.192 18.808C7.32067 18.936 7.46167 19 7.615 19H16.385C16.5383 19 16.6793 18.936 16.808 18.808C16.936 18.6793 17 18.5383 17 18.385V5.99998Z',
    theme: 'M8.954 20H5C4.71933 20 4.48267 19.9033 4.29 19.71C4.09667 19.518 4 19.2813 4 19V15.046C4.56933 14.8793 5.045 14.5643 5.427 14.101C5.809 13.6377 6 13.104 6 12.5C6 11.896 5.809 11.3623 5.427 10.899C5.045 10.4357 4.56933 10.1207 4 9.95399V5.99999C4 5.71932 4.09667 5.48266 4.29 5.28999C4.482 5.09666 4.71867 4.99999 5 4.99999H9C9.18 4.42799 9.49533 3.97099 9.946 3.62899C10.3973 3.28632 10.9153 3.11499 11.5 3.11499C12.0847 3.11499 12.6027 3.28632 13.054 3.62899C13.5047 3.97099 13.82 4.42799 14 4.99999H18C18.2807 4.99999 18.5173 5.09666 18.71 5.28999C18.9033 5.48266 19 5.71932 19 5.99999V9.99999C19.572 10.18 20.029 10.4953 20.371 10.946C20.7137 11.3973 20.885 11.9153 20.885 12.5C20.885 13.0847 20.7137 13.6027 20.371 14.054C20.029 14.5047 19.572 14.82 19 15V19C19 19.2807 18.9033 19.5173 18.71 19.71C18.5173 19.9033 18.2807 20 18 20H14.046C13.866 19.3973 13.5427 18.9133 13.076 18.548C12.6087 18.1827 12.0833 18 11.5 18C10.9167 18 10.3913 18.1827 9.924 18.548C9.45733 18.9133 9.134 19.3973 8.954 20Z',
    add: 'M11.5 16.5H12.5V12.5H16.5V11.5H12.5V7.5H11.5V11.5H7.5V12.5H11.5V16.5ZM12.003 21C10.759 21 9.589 20.764 8.493 20.292C7.39767 19.8193 6.44467 19.178 5.634 18.368C4.82333 17.5587 4.18167 16.6067 3.709 15.512C3.23633 14.4173 3 13.2477 3 12.003C3 10.759 3.236 9.589 3.708 8.493C4.18067 7.39767 4.822 6.44467 5.632 5.634C6.44133 4.82333 7.39333 4.18167 8.488 3.709C9.58267 3.23633 10.7523 3 11.997 3C13.241 3 14.411 3.236 15.507 3.708C16.6023 4.18067 17.5553 4.822 18.366 5.632C19.1767 6.44133 19.8183 7.39333 20.291 8.488C20.7637 9.58267 21 10.7523 21 11.997C21 13.241 20.764 14.411 20.292 15.507C19.8193 16.6023 19.178 17.5553 18.368 18.366C17.5587 19.1767 16.6067 19.8183 15.512 20.291C14.4173 20.7637 13.2477 21 12.003 21Z',
    circle: 'M12.003 21C10.759 21 9.589 20.764 8.493 20.292C7.39767 19.8193 6.44467 19.178 5.634 18.368C4.82333 17.5587 4.18167 16.6067 3.709 15.512C3.23633 14.4173 3 13.2477 3 12.003C3 10.759 3.236 9.589 3.708 8.493C4.18067 7.39767 4.822 6.44467 5.632 5.634C6.44133 4.82333 7.39333 4.18167 8.488 3.709C9.58267 3.23633 10.7523 3 11.997 3C13.241 3 14.411 3.236 15.507 3.708C16.6023 4.18067 17.5553 4.822 18.366 5.632C19.1767 6.44133 19.8183 7.39333 20.291 8.488C20.7637 9.58267 21 10.7523 21 11.997C21 13.241 20.764 14.411 20.292 15.507C19.8193 16.6023 19.178 17.5553 18.368 18.366C17.5587 19.1767 16.6067 19.8183 15.512 20.291C14.4173 20.7637 13.2477 21 12.003 21ZM12 20C14.2333 20 16.125 19.225 17.675 17.675C19.225 16.125 20 14.2333 20 12C20 9.76667 19.225 7.875 17.675 6.325C16.125 4.775 14.2333 4 12 4C9.76667 4 7.875 4.775 6.325 6.325C4.775 7.875 4 9.76667 4 12C4 14.2333 4.775 16.125 6.325 17.675C7.875 19.225 9.76667 20 12 20Z',
    check: 'M10.562 14.492L8.065 11.996C7.97167 11.9027 7.857 11.8527 7.721 11.846C7.585 11.8393 7.464 11.8893 7.358 11.996C7.25133 12.1027 7.198 12.2207 7.198 12.35C7.198 12.4793 7.25133 12.5973 7.358 12.704L9.996 15.342C10.1573 15.504 10.346 15.585 10.562 15.585C10.7773 15.585 10.9657 15.504 11.127 15.342L16.604 9.865C16.6973 9.77167 16.7473 9.657 16.754 9.521C16.7607 9.385 16.7107 9.264 16.604 9.158C16.4973 9.05133 16.3793 8.998 16.25 8.998C16.1207 8.998 16.0027 9.05133 15.896 9.158L10.562 14.492ZM12.003 21C10.759 21 9.589 20.764 8.493 20.292C7.39767 19.8193 6.44467 19.178 5.634 18.368C4.82333 17.5587 4.18167 16.6067 3.709 15.512C3.23633 14.4173 3 13.2477 3 12.003C3 10.759 3.236 9.589 3.708 8.493C4.18067 7.39767 4.822 6.44467 5.632 5.634C6.44133 4.82333 7.39333 4.18167 8.488 3.709C9.58267 3.23633 10.7523 3 11.997 3C13.241 3 14.411 3.236 15.507 3.708C16.6023 4.18067 17.5553 4.822 18.366 5.632C19.1767 6.44133 19.8183 7.39333 20.291 8.488C20.7637 9.58267 21 10.7523 21 11.997C21 13.241 20.764 14.411 20.292 15.507C19.8193 16.6023 19.178 17.5553 18.368 18.366C17.5587 19.1767 16.6067 19.8183 15.512 20.291C14.4173 20.7637 13.2477 21 12.003 21Z',
};

export default function Icon({ fill, width, height, type, onPress }: IconProps) {
    return (
        <Svg width={width} height={height} onPress={onPress} viewBox="0 0 24 24">
            <Path d={svgPaths[type]} fill={fill} />
        </Svg>
    );
}

2주차에는 한 svg 안에 여러 path가 가능하게 되면서 기존 객체에서 path 정보만 string이 아닌 배열로 관리해보았다.

export cosnt ICONS = {
  Gallery: [
    'M9.60596 4.76768C9.60596 4.32138 9.96549 3.95959 10.409 3.95959H10.417C10.8605 3.95959 11.2201 4.32138 11.2201 4.76768C11.2201 5.21397 10.8605 5.57576 10.417 5.57576H10.409C9.96549 5.57576 9.60596 5.21397 9.60596 4.76768Z',
    'M0 3.9596C0 1.81934 1.73622 0 3.98484 0H12.0152C14.2638 0 16 1.81934 16 3.9596V12.0404C16 14.1807 14.2638 16 12.0152 16H3.98484C1.73622 16 0 14.1807 0 12.0404V3.9596ZM3.98484 1.45455C2.68543 1.45455 1.54541 2.52953 1.54541 3.9596V12.0404C1.54541 13.4705 2.68543 14.5455 3.98484 14.5455H12.0152C13.3146 14.5455 14.4546 13.4705 14.4546 12.0404V3.9596C14.4546 2.52953 13.3146 1.45455 12.0152 1.45455H3.98484Z',
    'M3.70413 6.9021C4.29434 6.33059 5.08102 5.92334 5.99236 5.92334C6.90371 5.92334 7.69038 6.33059 8.2806 6.9021L8.2857 6.90704L8.30174 6.92292L12.3169 10.9634C12.6038 11.252 12.5959 11.7125 12.2994 11.9917C12.0028 12.271 11.5298 12.2634 11.2429 11.9747L7.23024 7.93676L7.22443 7.93102C6.84779 7.56726 6.42246 7.37789 5.99236 7.37789C5.56226 7.37789 5.13693 7.56726 4.76028 7.93103L4.75445 7.93679L1.54483 11.1666C1.25794 11.4553 0.784962 11.4629 0.4884 11.1836C0.191837 10.9044 0.183992 10.444 0.470877 10.1553L3.688 6.91792L3.70413 6.9021Z',
    'M9.3511 8.50988C9.94007 7.93958 10.7183 7.53955 11.6136 7.53955C12.5088 7.53955 13.2871 7.93958 13.876 8.50988L13.8811 8.51473L13.897 8.53053L15.5031 10.1467C15.7853 10.4307 15.7853 10.8912 15.5031 11.1752C15.2208 11.4593 14.7632 11.4593 14.481 11.1752L12.8774 9.56155L12.8714 9.55571C12.4935 9.19068 12.0598 8.9941 11.6136 8.9941C11.1674 8.9941 10.7336 9.19069 10.3557 9.55571L10.3498 9.56155L9.54921 10.3672C9.26696 10.6512 8.80936 10.6512 8.52711 10.3672C8.24487 10.0831 8.24487 9.62266 8.52711 9.33864L9.33506 8.52561L9.3511 8.50988Z',
  ],
};

이 방식들은 svg가 path로만 이루어진다는 보장이 없다면 사용할 수 없어 이 때부터 서치를 시작해보았다.
덕분에 3주차에 당근마켓에서 관리할 때 사용한다는 svg sprites 방식에 대해 알게 되었지만, 아쉽게도 react native에서는 사용할 수 없는 문제가 있었다.
(그래도 이후 프로젝트에서 잘 써먹었다.)


이 때부터 가장 합리적인 아이콘 컴포넌트 관리 방법에 대해 파보고싶다는 열망이 생겼다.
그리고 최근 약 5개월만에 (드디어) 미친듯이 파봤다.

집념 ON

다음은 그동안 인터넷에서 발견한 모든 아이콘 컴포넌트 관련된 방법론을 정리해본 것이다.

참고로 이걸 다 해봤다.

0. SVG 준비하기

지난 블로그 포스팅에서 피그마 플러그인을 만들어 SVG 파일을 추출했었다.
이번에는 이 플러그인으로 생성된 아이콘 정보 파일에서부터 작업을 시작해보았다.

피그마 커뮤니티 중 많은 아이콘 컴포넌트를 제공하는 디자인을 찾아 플러그인으로 추출해보았다.

{
  "Iconsans/Bold/Flash": {
    "svg": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n<path d=\"M6 11.74L12.56 3.55002C12.6893 3.38887 12.8653 3.27171 13.0639 3.21466C13.2625 3.15761 13.4738 3.16347 13.6689 3.23143C13.8641 3.29939 14.0333 3.42611 14.1535 3.59418C14.2737 3.76225 14.3388 3.96342 14.34 4.17002V10C14.34 10.2652 14.4454 10.5196 14.6329 10.7071C14.8204 10.8947 15.0748 11 15.34 11H17.23C17.4018 11.0131 17.5674 11.0705 17.7105 11.1664C17.8537 11.2623 17.9696 11.3936 18.0471 11.5476C18.1245 11.7015 18.1609 11.8729 18.1527 12.045C18.1444 12.2171 18.0918 12.3842 18 12.53L12.5 20.4C12.3783 20.5744 12.2042 20.7054 12.003 20.774C11.8019 20.8427 11.584 20.8455 11.3811 20.782C11.1782 20.7184 11.0009 20.5919 10.8748 20.4207C10.7487 20.2495 10.6805 20.0426 10.68 19.83V14.37C10.68 14.1048 10.5746 13.8505 10.3871 13.6629C10.1996 13.4754 9.94522 13.37 9.68 13.37H6.78C6.59076 13.3707 6.40523 13.3176 6.24495 13.217C6.08467 13.1164 5.95623 12.9724 5.87455 12.8017C5.79286 12.631 5.76129 12.4406 5.7835 12.2527C5.8057 12.0648 5.88078 11.887 6 11.74Z\" fill=\"#000D26\"/>\n</svg>\n",
    "id": "Iconsans/Bold/Flash"
  }
}

위 형태의 객체 파일 약 2600줄 정도가 생겼다.

1. 개별 SVG 파일 생성하기

개별 SVG 파일을 만드는 것은 쉽다.
이미 피그마 플러그인을 통해 전달받은 아이콘 JSON 파일에서 svg 데이터를 string으로 전해주기 때문에, 해당 데이터를 내용으로 하는 svg 파일을 생성해주면 된다.

// node js의 파일 시스템을 사용해 아이콘 JSON 파일 경로 접근 및 파일 읽기
const iconsJSONFile = getIconJSON();

// JSON 파일에서 가져온 svg string을 통해 svg 파일 생성하기
Object.values(iconsJSONFile).forEach(({ id, svg }) => {
	const svgName = getSVGName(id);
	const svgPath = path.join(DEFAULT_INDIVIDUAL_ICONS_PATH, `${svgName}.svg`);
	fs.writeFileSync(svgPath, svg, "utf8");
});


해당 스크립트를 통해 663개의 svg 파일을 생성했고, 약 0.422초 정도 걸렸다.

2. icon sprites 파일 생성하기

이번에는 icon sprites 파일을 생성해보았다.

icon sprites란?

icon sprites는 하나의 icon svg 파일을 만들고 실제 아이콘을 사용하는 부분에서 id 값을 통해 해당 아이콘만 가져올 수 있는 방법이다.

<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
    <symbol id="linearArrowDown" viewBox="0 0 24 24" fill="none">
    <path d="M12 18V6" stroke="#000D26" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
    <path d="M15.6 14.4L12 18L8.40002 14.4" stroke="#000D26" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
  </symbol>

  <symbol id="linearArrowLeft" viewBox="0 0 24 24" fill="none">
    <path d="M6 12H18" stroke="#000D26" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
    <path d="M9.6 15.6L6 12L9.6 8.4" stroke="#000D26" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
  </symbol>
	...
</svg>

파일 최상단에 모든 svg를 감싸줄 svg 태그를 생성하고, 내부에 svg들의 태그를 symbol로 바꿔서 넣어주면 손쉽게 icon sprites를 얻을 수 있다.

<svg width={24} height={24}>
  <use href={`${sprite}#${name}`} />
</svg>	

추가로, 사용하는 곳에서 use 태그의 href에 sprites 파일 위치#svg id를 통해 해당 아이콘을 식별하여 가져올 수 있으므로, sprites 파일에서 각 아이콘에 적합한 id를 추가해놓아야 한다.

gulp로 자동화

절대로 귀찮았던 것은 아니고, 아직 icon sprites에서 사용할 수 있는 모든 기능에 대해 마스터한 것은 아니라 직접 템플릿을 만들어 스크립트를 짜면 일부 기능을 사용할 수 없는 sprites 파일이 될 수도 있을 것이라는 생각이 들었다.
그래서 기존에 존재하는 자동화 도구를 도입해보자는 생각이 들었고, gulp로 만들어진 몇몇 도구를 찾을 수 있었다.
gulp-svg-sprite, gulp-svg-sprites, gulp-svgstore 등을 발견할 수 있었는데, 다들 너무 오래된 코드들이라 그나마 가장 최근에 코드 업데이트가 있었던 gulp-svg-sprite를 사용해 보았다.

gulp와 해당 플러그인을 설치한 뒤, gulpfiles.js에 스크립트를 작성해보았다.
1. 개별 SVG 파일 생성하기에서 생성했던 svg 파일들을 넘겨주고 plugin을 거쳐 원하는 위치에 icon sprites 파일이 생성되도록 작성했다.

import gulp from "gulp";
import svgSprite from "gulp-svg-sprite";
import path from "path";

const iconSpriteFilePath = path.join(process.cwd());

const createIconSpriteFile = () => {
	return gulp
    	// 1에서 생성했던 개별 svg 파일들
		.src("files/individual/default/**/*.svg")
		.pipe(
      	// svg 파일들을 선택한 gulp plugin에 넘기면 지정한 위치에 파일이 생성된다.
			svgSprite({
				mode: {
					symbol: {
						dest: iconSpriteFilePath,
						sprite: "files/sprites/optimizedSVGSprites.svg",
					},
				},
			})
		)
		.pipe(gulp.dest("./"));
};

export default createIconSpriteFile;


실험 결과 663개의 svg가 통합된 하나의 svg 파일이 생성되었고, 1.39초가 걸렸다.
이 과정에서 아쉬웠던 점은, 어차피 나에게는 svg 정보를 string으로 저장하는 icons.json 파일이 있는데도 개별 svg 파일을 추출한 결과를 넘겨주어야했다는 것이다.
실제 프로덕트 입장에서 생각해보면 무조건 개별 svg 파일로의 변환을 거쳐 icon sprites를 생성할 수 있으니 svg string만으로 해결해보고 싶었다.

삽질의 시작

선택했던 gulp-svg-sprite를 가보니, svg-sprite라는 라이브러리를 기반으로 코드가 작성되어 있었다.
해당 라이브러리에서는 SVGSpriter라는 클래스가 있었는데, 해당 클래스에 add라는 메서드를 통해 svg를 추가할 수 있었다.

SVGSpriter.prototype.add = function(file = '', name = '', svg = '') {
  // If no vinyl file object has been given
  if (!this._isVinylFile(file)) {
    file = file.trim();
    let errorMessage = null;

    // If the name part of the file path is absolute
    if (name && path.isAbsolute(name)) {
      errorMessage = format('SVGSpriter.add: "%s" is not a valid relative file name', name);
    } else {
      if (name) {
        name = trimStart(name.trim(), `${path.sep}.`);
      }

      name = name || path.basename(file);
      // TODO: Avoid Buffer -> String -> Buffer conversion of svg.
      if (Buffer.isBuffer(svg)) {
        svg = svg.toString();
      }

      svg = svg.trim();

      // Argument validation
      if (arguments.length < 3) {
        errorMessage = 'SVGSpriter.add: You must provide 3 arguments';
      } else if (!file.length) {
        errorMessage = format('SVGSpriter.add: "%s" is not a valid absolute file name', file);
      } else if (!name.length) {
        errorMessage = format('SVGSpriter.add: "%s" is not a valid relative file name', name);
      } else if (!svg.length) {
        errorMessage = 'SVGSpriter.add: You must provide SVG contents';
      } else if (!file.endsWith(name)) {
        errorMessage = format('SVGSpriter.add: "%s" is not the local part of "%s"', name, file);
      }
    }

    // In case of an error: Throw it!
    if (errorMessage) {
      const error = new ArgumentError(errorMessage);
      this.error(errorMessage, error);
      throw error;
    }

    // Resolve path before splitting it into base and path
    // so that Shape will properly extract a shape name later on.
    file = path.resolve(file);

    // Instantiate a vinyl file
    file = new File({
      base: file.substring(0, file.length - name.length),
      path: file,
      contents: Buffer.from(svg)
    });
  }

  file.base = path.resolve(file.base);

  // Add the shape to the internal processing queue
  this._queue.add(file);

  return this;
};

위 코드는 해당 라이브러리에서 add 메서드 부분만 발췌를 한 것이다.
해당 함수는 vinylFile이나 실제 존재하는 파일에 대한 경로를 입력받는다.
결국 내부적으로 vinylFile이 아니라면 전달받은 경로를 기반으로 vinyl file을 생성하여 동작하고 있었다.

vinyl file은 찾아보니 일종의 가상 파일 형식이었고, 내가 해당 함수에 전달하고자 하는 icon 정보는 파일의 일부 데이터였기 때문에 이 일부만을 바탕으로 가상 파일을 만들면 되는 것이 아닐까 생각했다.

  • 아무 경로나 입력해서도 만들어보았으나 실제 add가 동작할 때 vinyl file의 path가 존재하는지 검사하는 로직이 포함되어 있어 사용할 수 없었다.
  • 빈 파일을 만들어 해당 경로로 가상 파일을 생성하니 작성한 contents가 덮어씌워지는 것이 아니라 빈 파일 자체가 add되어 사용할 수 없었다.

해당 플러그인은 가상 파일을 만들어 contents를 전달해도 이 contents를 읽는 것이 아니라 가상 파일에 작성한 파일의 path를 찾아가 contents를 읽어오는 로직으로 작성되어 있었기 때문에 무조건 개별 svg 파일 생성 단계를 거칠 수 밖에 없다는 결론을 내렸다.

그래서 아까 찾은 다른 플러그인들로 바꿔서 시도해볼까 생각을 했는데, 대다수의 프로젝트에서는 icon contents 정보를 string으로 들고 있는 것보다 icon svg 파일 자체를 가지고 있는 것 이 더 일반적인 상황이라 어차피 전자는 고려되어 있지 않을 것이라는 생각이 들었다.

sprites의 일부 기능이 누락될 수는 있겠지만 직접 템플릿을 만들어 자동화 스크립트 짜는 방법으로 변경해보았다.

템플릿 만들어 스크립트 짜기

어차피 svg의 contents 정보를 string으로 가지고 있기 때문에 replace를 사용해서, 각 svg마다 태그를 symbol 태그로 바꾸고, 각 아이콘을 특정할 수 있는 id도 넣어주었다. (일부 불필요한 정보들도 지워주었다.)
이렇게 변경된 string을 svg 태그로 감싸기만 하면 간단한 형식의 sprites 파일이 완성된다.

import fs from "fs";

import { DEFAULT_SVG_SPRITES_PATH } from "../constants/path.js";
import { getIconJSON } from "./utils/getFile.js";
import { getSVGName } from "./utils/transform.js";

const iconsJSONFile = getIconJSON();

const generateIconSvgContents = (svg, name) => {
	return svg
		.replace(/<\?xml.*?\?>/, "")
		.replace(/ id=".*?"/, "")
		.replace(/ version=".*?"/, "")
		.replace(/ xmlns=".*?"/, "")
		.replace(/ xmlns:xlink=".*?"/, "")
		.replace(/ width=".*?"/, "")
		.replace(/ height=".*?"/, "")
		.replace("<svg", `<symbol id="${name}"`)
		.replace("</svg>", "</symbol>\n");
};

let generatedIcons = "";

Object.values(iconsJSONFile).forEach(({ id, svg }) => {
	const svgName = getSVGName(id);
	generatedIcons += generateIconSvgContents(svg, svgName);
});

const iconSpriteFile = `
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
    ${generatedIcons}
</svg>
`;

fs.writeFileSync(DEFAULT_SVG_SPRITES_PATH, iconSpriteFile, "utf8");


실험 결과로 gulp로 만들었던 파일과 유사한 단일 svg 파일이 0.187초만에 생성되었다.

최적화...


gulp 방식이 직접 만든 방식보다 거의 10배는 더 오래 걸렸다.

파일 크기는 gulp를 통해 만든 파일이 3~4배는 더 적다.

참고로 개별 svg 파일을 생성했던 폴더 크기는 856KB로 직접 만들었던 svg sprites와 거의 유사한 수치였다.
누가 봐도 gulp를 사용한 자동화에서 파일 크기를 최적화시켰고 이 과정이 시간은 오래 걸렸던 대신 파일 크기를 작게 만든 것으로 보인다.
어마무시한 파일 길이지만, gulp를 사용했던 결과물과 직접 만들었던 결과물을 직접 비교해보면,



(전자가 gulp를 사용한 방식, 후자가 직접 만든 방식이다)
g 태그 밑에 있던 여러 path가 단일 path 태그로 최적화되었고, rect와 같은 도형 태그가 path 태그로 변환되었음을 알 수 있다.
덕분에 훨씬 코드 길이가 짧아질 수 있었다.

이와 유사한 내용을 쏘카 블로그에서 본 적이 있는데, svg에서 제공하는 모든 도형은 path로 변환될 수 있고, 이게 가능하다면 여러 path를 하나의 path로 압축하는 것도 가능할 것이다.
아쉽게도 해당 블로그에서 어떻게 path를 최적화하는지에 대한 정보는 공개하지 않았다.
집념으로 github 코드까지 다 뒤져

convertSvgPath 스크립트를 공개하면 좋겠다는 피드백은 찾았으나 결국 공개되지 않았다...

아쉬운 대로 gulp 방식의 코드를 마구 뒤졌다.
아까보았던 svg-sprite 라이브러리 코드에서 transform에 대한 동작 코드를 찾을 수 있었고, 이 transform은 svgo에서 오고 있었다.

svgo


SVGO(SVG Optimizer)는 SVG 파일을 최적화하는 도구로, 불필요한 데이터를 제거하고 파일 크기를 줄여 성능을 향상시키는 데 사용된다.
그리고 이 최적화를 위해 아주 많은 plugin들을 제공하고 있고, 그 중 여러 path를 합쳐주는 mergePaths도 있다.

svgo의 플레이그라운드를 통해 svg 최적화를 실험해볼 수 있고, 위 사진의 경우 단일 svg 용량을 8.54k → 5.07k로 59.34%나 최적화시켰다.

3. 웹 폰트 자동화

아이콘 sprites와 유사하게 아이콘을 모아 한 파일에서 관리하는데, 웹 폰트의 경우 파일 형식이 svg가 아닌 폰트 파일 형식을 사용한다.
가장 기대했던 방식인데 막상 검색해보니 요즘엔 잘 사용되지 않는 방식인 것 같다.

이유 1. 자동화하기 쉽지 않음

검색을 해보면 대다수의 사람들이 아이콘을 업데이트하면 자동으로 웹 폰트로 변환해주는 웹 서비스를 많이들 사용하고 있었다.
매번 직접 웹 사이트에 들어가서 아이콘 넣어서 파일 변환하는 과정을 거치기엔 공수가 클 것 같고, 저런 웹 사이트가 여럿 존재한다는 것은 자동화할 수 있는 스크립트가 존재하기 때문이라는 생각에 cli 명령어를 제공하는 라이브러리를 찾아보았다.
한 3개 정도 찾았었는데 윈도우에서는 웹 폰트를 자동화하는 cli 명령어가 동작하지 않는 등의 버그가 있어서 결국엔 @twbs/fantasticon를 사용하게 되었다.

이유 2. 라이브러리 버그?

@twbs/fantasticon를 설치하고

// .fantasticonrc.json
{
	"inputDir": "./files/individual/default",
	"outputDir": "./fonts",
	"fontTypes": ["ttf", "woff", "woff2"],
	"assetTypes": ["ts", "css", "json", "html"],
	"fontsUrl": "/static/fonts",
	"formatOptions": {
		"woff": {
			"metadata": "..."
		},
		"json": {
			"indent": 2
		},
		"ts": {
			"types": ["constant", "literalId"],
			"singleQuotes": true,
			"enumName": "IconType",
			"constantName": "ICON_TYPE"
		}
	},
	"templates": {
		"css": "./fonts.css.hbs"
	},
	"pathOptions": {
		"ts": "./iconTypes.ts",
		"json": "./iconTypes.json"
	}
}

원하는 설정을 json 파일에 명시하여

"scripts": {
  "webfont": "fantasticon -c .fantasticonrc.json"
},

package.json에 webfont를 자동으로 생성해주는 스크립트를 연결해주었다.

지정한 경로에 웹 폰트 파일과 아이콘들을 확인해볼 수 있는 html 등이 생성된다.
하지만,

멀쩡한 대부분의 아이콘 사이 렌더링 되지 않는 일부 아이콘이 존재함을 확인할 수 있다.
라이브러리 자체의 문제인지 아니면 한 번에 너무 많은 아이콘을 웹 폰트로 변환하면서 일부가 누락이 된 것인지는 알 수 없으나 이런 원인들이 쌓여 웹 폰트를 잘 사용되지 않도록 만든 것이 아닐까 생각이 든다.

4. 개별 아이콘 컴포넌트 자동으로 생성하기

다음과 같이 단순히 각 svg를 컴포넌트화시키는 것을 개별 아이콘 컴포넌트로 표현해보았다.

const BoldActivity = () => {
	return (
		<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
			<path
				d="M5.00002 15.75C4.85986 15.7495 4.72264 15.7098 4.60391 15.6354C4.48517 15.5609 4.38968 15.4547 4.32825 15.3287C4.26683 15.2027 4.24193 15.0621 4.25638 14.9227C4.27083 14.7833 4.32405 14.6507 4.41002 14.54L7.50002 10.54C7.7577 10.2125 8.0877 9.94908 8.46417 9.77039C8.84064 9.59169 9.25336 9.50257 9.67002 9.50999C10.0888 9.5084 10.5023 9.60318 10.8786 9.78698C11.2549 9.97079 11.5838 10.2387 11.84 10.57L13.35 12.51C13.471 12.6584 13.6235 12.7781 13.7965 12.8603C13.9694 12.9424 14.1585 12.9851 14.35 12.9851C14.5415 12.9851 14.7306 12.9424 14.9036 12.8603C15.0765 12.7781 15.229 12.6584 15.35 12.51L18.44 8.50999C18.567 8.36895 18.7426 8.28127 18.9317 8.26461C19.1207 8.24796 19.309 8.30357 19.4586 8.42025C19.6083 8.53692 19.7081 8.70598 19.7381 8.89335C19.768 9.08073 19.7258 9.27249 19.62 9.42999L16.53 13.43C16.2754 13.763 15.9468 14.0322 15.5701 14.2161C15.1935 14.4001 14.7792 14.4939 14.36 14.49C13.9412 14.4916 13.5277 14.3968 13.1514 14.213C12.7751 14.0292 12.4462 13.7613 12.19 13.43L10.68 11.49C10.559 11.3415 10.4065 11.2219 10.2336 11.1397C10.0606 11.0575 9.87151 11.0149 9.68002 11.0149C9.48852 11.0149 9.29943 11.0575 9.12646 11.1397C8.95349 11.2219 8.80099 11.3415 8.68002 11.49L5.59002 15.49C5.51719 15.5745 5.42644 15.6416 5.32438 15.6866C5.22232 15.7316 5.1115 15.7533 5.00002 15.75Z"
				fill="#000D26"
			/>
		</svg>
	);
};

export default BoldActivity;

이를 자동화로 만들기 위해 일종을 템플릿을 만들고 처음 생성했던 svg 파일들을 넣어주었다.

// 일종의 컴포넌트 모양 템플릿
const generateIconComponent = (id, svg) => {
	return `
     const ${toPascalCase(id)} = () => {
        return (
            ${svg}
        );
    };

    export default ${toPascalCase(id)};
    `;
};

// 개별 svg 파일들이 존재하는 폴더를 읽어서
// 각 id와 svg를 컴포넌트 생성하는 함수에 집어넣어준다.
const createIndividualIconComponents = () => {
	const iconSVGFiles = fs.readdirSync(DEFAULT_INDIVIDUAL_ICONS_PATH);
	iconSVGFiles.forEach((iconSVGFile) => {
		const svg = fs.readFileSync(path.join(DEFAULT_INDIVIDUAL_ICONS_PATH, iconSVGFile), "utf8");
		const id = iconSVGFile.split(".")[0];
		fs.writeFileSync(
			path.join(DEFAULT_INDIVIDUAL_ICON_COMPONENTS_PATH, `${toPascalCase(id)}.jsx`),
			generateIconComponent(id, svg),
			"utf8"
		);
	});
};

추가로, 스토리북을 생성하여 아이콘을 확인해볼 때 일일이 import하지 않아도 되도록 배럴 파일을 추가해준다.

const createBarrelFile = () => {
	const iconSVGFiles = fs.readdirSync(DEFAULT_INDIVIDUAL_ICONS_PATH);
	const barrelFileContent = iconSVGFiles
		.map((iconSVGFile) => {
			const id = iconSVGFile.split(".")[0];
			return `export { default as ${toPascalCase(id)} } from "./${toPascalCase(id)}.jsx";`;
		})
		.join("\n");

	fs.writeFileSync(path.join(DEFAULT_INDIVIDUAL_ICON_COMPONENTS_PATH, "index.jsx"), barrelFileContent, "utf8");
};

0.519초만에 각 아이콘 컴포넌트와 배럴 파일이 생성되었다.

5. 통합 아이콘 컴포넌트 생성하기

하나의 아이콘 컴포넌트를 만들어서 아이콘을 특정할 수 있는 id를 통해 이에 해당하는 아이콘을 생성하는 방법을 통합 아이콘 컴포넌트로 이름 지어보았다.

sprites 파일을 사용할 수 있는 아이콘 컴포넌트를 통합 아이콘 컴포넌트로 생성해보았다.

// 아이콘 sprites 파일을 불러오고
import sprite from "../../files/sprites/defaultSVGSprites.svg";

const SpriteIcon = ({ name }) => {
	return (
		<svg width={24} height={24}>
      		// id를 통해 특정 아이콘을 지칭한다.
			<use href={`${sprite}#${name}`} />
		</svg>
	);
};

export default SpriteIcon;

// 실제 사용 시 모습
<SpriteIcon name={name} />

6. SVGR

SVGR(SVG to React)은 SVG 파일을 React 컴포넌트로 변환해주는 도구이다.
SVGR 라이브러리를 설치하고 package.json에 svgr을 실행하는 스크립트를 작성해준다.

"scripts": {
    "generateSVGR": "npx @svgr/cli --icon --ext jsx --out-dir component/svgr files/individual/default",
},

import * as React from "react";
const SvgBoldActivity = (props) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    width="1em"
    height="1em"
    fill="none"
    viewBox="0 0 24 24"
    {...props}
  >
    <path
      fill="#000D26"
      d="M5 15.75a.751.751 0 0 1-.59-1.21l3.09-4a2.7 2.7 0 0 1 2.17-1.03 2.73 2.73 0 0 1 2.17 1.06l1.51 1.94a1.29 1.29 0 0 0 2 0l3.09-4a.75.75 0 0 1 1.18.92l-3.09 4a2.7 2.7 0 0 1-2.17 1.06 2.73 2.73 0 0 1-2.17-1.06l-1.51-1.94a1.29 1.29 0 0 0-2 0l-3.09 4a.75.75 0 0 1-.59.26"
    />
  </svg>
);
export default SvgBoldActivity;

실행 결과 위와 같은 컴포넌트가 생성된다.
터미널에 실행 시간이 뜨지는 않았지만, 개별 아이콘 컴포넌트를 직접 만드는 스크립트보다는 시간이 더 걸렸다.
다만, gulp를 통해 icon sprites를 생성하던 것과 마찬가지로 svg를 아이콘 컴포넌트로서 변경하면서 사용할 수 있는 모든 기능을 제공해주기 위한 추가적인 설정이 있어서 시간이 차이가 나는 것 같다.

7. 아이콘 컴포넌트 비교


이제 스토리 파일을 하나 생성하여 모든 아이콘 컴포넌트를 같은 크기로 그려보았다.
각 아이콘 순서는 sprites 파일로 생성한 통합 아이콘 컴포넌트, 직접 svg 파일을 컴포넌트로 변환한 개별 아이콘 컴포넌트, SVGR로 생성한 아이콘 컴포넌트 순이다.
웹 폰트 방식과 달리 모든 아이콘 컴포넌트가 빠짐없이 렌더링되었다.

개별 아이콘 컴포넌트


스토리북에 개별 아이콘 컴포넌트에 대한 스토리를 작성하면 네트워크 탭에서 엄청난 요청 횟수를 확인할 수 있다.
아마도 화면에 그려지는 아이콘 자체는 하나지만, 배럴 파일을 통해 아이콘을 가져오고 있어서 모든 아이콘을 다 요청하는 것 같다.

통합 아이콘 컴포넌트

반면 sprites 파일을 사용한 통합 아이콘 컴포넌트를 불러오면

아이콘 svg 파일과 jsx, 스토리북 단 세 건에 대한 네트워크 요청이 존재함을 알 수 있다.
대신 개별 아이콘 컴포넌트 네트워크 요청에서 svg 파일 하나의 크기는 200~300B 정도였는데 sprites 아이콘 svg 파일 하나의 크기는 225KB로 단위가 훨씬 크다.
대신 동일하게 모든 아이콘을 불러온다는 조건에서는 sprites로 만든 아이콘을 로딩하는 것이 훨씬 빠르게 느껴졌다.

외전: prettier 적용하기

추가로 chakra UI를 구경하던 중, prettier를 package.json의 script에 적용하지 않고, prettier 라이브러리의 formatter 함수를 불러와 적용하는 흥미로운 코드를 발견해 따라해보았다.

const formatSVGFile = (svg) => {
	const formattedSVG = format(svg, {
		parser: "html",
		printWidth: 80,
		tabWidth: 2,
		useTabs: false,
		semi: true,
		singleQuote: true,
	});

	return formattedSVG;
};

// 특정 파일에 대해 prettier 돌리기
const convertIconSpriteFile = async () => {
	const iconSpriteFile = fs.readFileSync(OPTIMIZED_SVG_SPRITES_PATH, "utf8");
	fs.writeFileSync(FORMATTED_OPTIMIZED_SVG_SPRITES_PATH, await formatSVGFile(iconSpriteFile), "utf8");
	const defaultSpritesFile = fs.readFileSync(DEFAULT_SVG_SPRITES_PATH, "utf8");
	fs.writeFileSync(FORMATTED_DEFAULT_SVG_SPRITES_PATH, await formatSVGFile(defaultSpritesFile), "utf8");
};

// 특정 폴더 하위에 대해 prettier 돌리기
const convertIconSVGFiles = async () => {
	const iconSVGFiles = fs.readdirSync(DEFAULT_INDIVIDUAL_ICONS_PATH);
	iconSVGFiles.forEach(async (iconSVGFile) => {
		fs.writeFileSync(
			path.join(FORMATTED_INDIVIDUAL_ICONS_PATH_JS, iconSVGFile),
			await formatSVGFile(
				fs.readFileSync(path.join(DEFAULT_INDIVIDUAL_ICONS_PATH, iconSVGFile), "utf8")
			),
			"utf8"
		);
	});
};

이렇게 하면 해당 파일을 변경하는 게 아니라 비교할 수 있게 별도의 파일을 생성할 수 있다.
위 코드로 몇 개 파일에 대해 prettier를 적용해보았는데 약 4% ~ 17% 정도 용량이 증가하였다.

profile
제일 재밌는 개발 블로그(희망 사항)

0개의 댓글