15개 저장소에 Phosphor Icons를 표준화하여 아이콘 번들 크기를 60% 줄인 이유
요약
15개 저장소에 분산된 5개의 아이콘 시스템을 Phosphor Icons로 통합하여 시각적 불일치를 해결하고 번들 크기를 평균 60% 절감한 사례를 다룹니다.
핵심 포인트
- 아이콘 시스템 통합을 통한 브랜드 시각적 일관성 확보
- Phosphor Icons 도입으로 번들 크기 최대 78% 감소
- ripgrep과 codemod을 활용한 신속한 대규모 마이그레이션
- 트리 쉐이킹 최적화를 통한 성능 및 사용자 경험 개선
15개의 저장소(repos)에 걸쳐 사용된 5개의 아이콘 시스템은 시각적 불일치(visual drift)를 야기했고, 최악의 Next.js 앱에서는 84KB의 번들을 생성했습니다. Phosphor는 한 명의 디자이너가 제작한 6가지 웨이트(weights)를 제공하므로, 스튜디오 전체가 하나의 제품처럼 읽힙니다. 리퀴드 테마(Liquid themes)는 Fill 웨이트를 사용하고, React 앱은 Regular를 사용하며, 동일한 서피스(surface) 내에서 혼용하지 않습니다. ripgrep과 작은 코드모드(codemod)를 사용하여 15개 저장소에 걸친 40개의 아이콘 마이그레이션(migration)을 단 한 오후 만에 끝냈습니다. 트리 쉐이킹(Tree-shaken)된 Phosphor는 18KB로, 최악의 경우 78%, 평균적으로 약 60%의 크기를 절감했습니다.
저는 화요일에 이 문제를 발견했습니다. 두 개의 RAXXO 탭을 나란히 열어두고 있었습니다. 하나는 Next.js 대시보드였고, 다른 하나는 Shopify 제품 페이지였습니다. 두 페이지 모두 주요 CTA(Call to Action) 근처에 작은 로켓 아이콘이 있었습니다. 아이콘의 의도, 크기, 색상은 모두 동일했습니다. 하지만 서로 연관되어 보이지 않았습니다. 대시보드의 아이콘은 선이 얇은 Lucide 로켓이었고, Shopify의 아이콘은 두툼한 Heroicons solid였습니다. 나란히 두었을 때 그것들은 마치 두 개의 서로 다른 브랜드처럼 보였습니다. 저는 사람들에게 RAXXO가 하나의 스튜디오, 하나의 시스템, 하나의 느낌이라고 말해왔습니다. 하지만 아이콘들은 저를 거짓말쟁이라고 부르고 있었습니다.
그날 오후, 저는 스튜디오 전체에서 실제로 사용 중인 아이콘 시스템이 몇 개인지 세기 시작했습니다. 답은 5개였습니다. 해결책은 하나였습니다. 이것은 어떻게 5개를 하나로 통합했는지, 왜 Phosphor가 승리했는지, 그리고 단순한 과시용 마이그레이션을 측정 가능한 성능 향상으로 바꾼 번들 수학(bundle math)에 대한 이야기입니다.
Phosphor 도입 전 사용하던 5개의 아이콘 시스템
15개 저장소에서 실제로 배포되고 있던 상황은 다음과 같습니다. Heroicons는 3개의 Next.js 앱에서 원래 선택된 옵션이었습니다. 일부 화면에는 solid가 필요하고 일부에는 outline이 필요했기 때문에 @heroicons/react/24/solid와 @heroicons/react/24/outline을 모두 사용했습니다. 이는 동일한 앱에 두 개의 아이콘 팩이 임포트(import)됨을 의미했습니다. 트리 쉐이킹(Tree-shaking)이 도움이 되긴 하지만, 저는 몇몇 곳에서 모듈 전체를 임포트하는 습관이 있었고, 최악의 앱은 클라이언트 번들(client bundle)에 84KB의 아이콘 코드를 끌어오고 있었습니다. Lucide는 제가 선의 굵기(stroke weight)를 좋아했기 때문에 두 개의 최신 Next.js 앱에 스며들었습니다. Lucide는 트리 쉐이킹이 더 잘 되어 비슷한 아이콘 개수 기준으로 31KB를 기록했습니다. 해당 앱 내부에서의 시각적 리듬은 괜찮았습니다.
문제는 Lucide의 선(stroke)과 Heroicons의 채워진(solid) 아이콘이 전혀 닮지 않았다는 점이었습니다. 이모지를 아이콘으로 사용하는 것은 세 개의 순수 HTML (vanilla HTML) 랜딩 페이지가 숨기고 있던 치부였습니다. CTA (Call To Action) 옆에 있는 로켓 이모지는 macOS Safari에서는 괜찮아 보입니다. 하지만 Windows Chrome에서는 마치 다른 폰트처럼 보입니다. Android에서는 작은 타이포그래피 사고처럼 보입니다. 저는 그 페이지들이 임시적인 것이라 괜찮다고 스스로를 다독였습니다. 하지만 그중 여러 페이지는 1년 동안 라이브 상태로 유지되었습니다. 커스텀 인라인 SVG (Custom inline SVGs)는 네 개의 Shopify 테마에 흩어져 있었습니다. 어떤 것들은 오래된 Figma 내보내기 파일에서 그대로 가져온 것이었고, 어떤 것들은 제가 새벽 2시에 직접 그린 것들이었습니다. 그것들은 일관된 선 두께(stroke width), 일관된 모서리 곡률(corner radius), 일관된 뷰포트 크기(viewport size)를 갖추고 있지 않았습니다. 몇 개는 24x24였고, 몇 개는 32x32였으며, 정체불명의 아이콘 하나는 20x20 크기에 1.5px 선 두께를 가지고 있어 주변의 모든 것보다 더 얇아 보였습니다. Font Awesome의 낙오자들은 제가 이전 작업에서 물려받은 두 개의 Shopify 테마에 남아 있었습니다. 단 4개의 아이콘을 렌더링하기 위해 전체 Font Awesome CSS를 로드하는 것은, 한 번 내린 뒤 2년 동안 무시하게 되는 그런 종류의 결정입니다. 저는 그것을 무시하고 있었습니다. 다섯 개의 시스템, 공유된 시각적 논리(visual logic)의 부재, 어느 화요일에나 마주하게 되는 네 가지의 서로 다른 선 두께. 그것이 문제였습니다.
Phosphor가 승리한 이유: 6개의 두께, 하나의 시각적 시스템
저는 세 가지 대체제를 평가했습니다. Heroicons는 기존 사용자였으나 두께(weight) 개수에서 패배했습니다. Lucide는 특히 트리 쉐이킹 (tree-shaking)과 React 사용 편의성(ergonomics) 측면에서 강력한 후보였습니다. Phosphor ( https://phosphoricons.com )는 Liquid, React, 그리고 순수 HTML (vanilla HTML)을 통해 결과물을 배포하는 스튜디오에게 가장 중요한 차원, 즉 하나의 패밀리 내에 있는 두께 옵션 덕분에 승리했습니다. Phosphor는 동일한 아이콘 세트에 대해 여섯 가지 두께를 제공합니다: Thin, Light, Regular, Bold, Fill, 그리고 Duotone. 모든 아이콘은 모든 두께에서 동일한 손에 의해 그려집니다. 이는 제가 한 플랫폼에는 더 무거운 두께를 선택하고 다른 플랫폼에는 더 가벼운 두께를 선택하더라도, 여전히 하나의 시스템으로 읽히게 할 수 있음을 의미합니다. 이는 사소한 일처럼 들릴 수 있습니다. 하지만 이것이 이 작업이 성공하는 전체 이유입니다. 다크 테마는 얇은 선을 잡아먹습니다. raxxo.shop의 경우, 배경색은 #1f1f21이고 텍스트는 #F5F5F7 입니다.
20px 크기에서 1.5px 굵기의 선(stroke)을 가진 아이콘은 배경 노이즈 속으로 사라져 버립니다. 반면 같은 크기의 채워진(filled) 아이콘은 존재감을 드러냅니다. Shopify 테마에는 채워진 굵기(Fill weight)가 필요했습니다. React 앱들은 곳곳에 더 밝은 배경을 가지고 있고, 레이아웃이 더 조밀하며, 화면당 더 많은 아이콘을 포함합니다. 그곳에 채워진 굵기를 사용하면 너무 눈에 띄어(shouty) 부담스러울 수 있습니다. 1.5px 선을 가진 일반 굵기(Regular weight)는 조밀한 UI에서 깔끔하게 자리 잡습니다. Heroicons를 사용할 때는 채워진 형태(solid) 또는 윤곽선 형태(outline)라는 두 가지 옵션 중 선택할 수 있었습니다. Lucide를 사용할 때는 하나의 굵기와 선 두께(stroke-width) 속성만 있었는데, 이는 직접 그려진 굵기 변형(weight variant)과는 다릅니다. Phosphor를 사용하면 동일한 디자이너가 그린 6가지 굵기를 사용할 수 있었고, 저는 그중 두 가지만 필요했습니다. 결정은 금요일에 내려졌습니다.
Liquid vs React 분리: 테마에는 Fill, 앱에는 Regular
스튜디오 전체에 적용한 규칙은 간단합니다. Liquid 테마에는 Phosphor Fill을 사용하고, React 및 Next.js 앱에는 Phosphor Regular를 사용합니다. 하나의 표면(surface)에서 두 가지 굵기를 혼용하지 않습니다. Shopify 테마에서는 패키지를 임포트(import)하지 않습니다. 대신 SVG 경로 데이터(path data)를 인라인(inline)으로 직접 넣습니다. 이렇게 하면 테마의 의존성(dependency)을 없앨 수 있고, 어떤 JavaScript가 로드되기 전에도 아이콘이 렌더링될 수 있습니다. 제가 모든 Liquid 스니펫(snippet)에서 사용하는 관례는 다음과 같습니다.
{% comment %} Phosphor Fill 굵기, 인라인 SVG, JS 의존성 없음 {% endcomment %}
ph-fill과 ph-rocket-launch 클래스는 Phosphor의 것이 아니라 제가 만든 것입니다. 이 클래스들은 SVG 속성을 건드리지 않고도 호버(hover) 상태나 색상 재정의(color overrides)를 위한 CSS 훅(hook) 역할을 합니다. Phosphor 사이트에서 원시 경로 데이터(raw path data)를 가져와 Liquid 스니펫에 한 번 붙여넣습니다. 그 이후부터 아이콘은 단순히 {% render 'icon-rocket-launch' %}로 호출하면 됩니다.
React 앱에서는 공식 패키지인 @phosphor-icons/react를 임포트합니다. 개별 컴포넌트를 임포트할 때 트리 쉐이킹(tree-shaking)이 올바르게 작동합니다. 제가 모든 곳에서 사용하는 패턴은 다음과 같습니다.
import { RocketLaunch } from '@phosphor-icons/react';
export function CTA () {
return (
<RocketLaunch />
);
}
weight="regular" 속성은 Regular가 기본값임에도 불구하고 명시적으로 작성합니다. 저는 이 관례가 코드에 명확히 보이기를 원합니다. 그래야 다음 작업자(또는 미래의 나)가 React 앱에 무심코 Fill 아이콘을 집어넣는 실수를 하지 않을 것이기 때문입니다.
Shopify ( https://shopify.pxf.io/5k5rj9 ) 테마 저장소와 Next.js 앱 저장소는 이제 동일한 어휘를 공유합니다. 로켓은 두 곳 모두에서 같은 의미를 갖습니다. 단지 장소에 따라 다른 옷을 입을 뿐입니다. 이는 제가 이 프로젝트들을 위해 4단계 다크 모드 (dark mode) 컬러 시스템을 구축할 때 사용했던 것과 동일한 논리입니다. 서로 다른 표면(surfaces), 공유된 규칙들입니다.
마이그레이션 (Migration): 아이콘 40개, 저장소 15개, 단 한 번의 오후
스튜디오는 15개 저장소 전체에 걸쳐 약 40개의 고유한 아이콘을 사용합니다. 저는 워크스페이스 전체에서 아이콘 임포트 (import) 문을 grep으로 검색하여 이를 확인했습니다. 첫 번째 단계는 인벤토리 (inventory) 파악이었습니다. ripgrep 덕분에 이 작업은 빨랐습니다.
rg "from '@heroicons" -l
rg "from 'lucide-react'" -l
rg "fa-(rocket|cart|user)" -l
저는 모든 검색 결과를 파일 경로, 기존 아이콘 이름, 그리고 의도한 Phosphor 대응 아이콘과 함께 스프레드시트에 쏟아부었습니다. 저장소별로 정렬된 40개의 행이 만들어졌습니다. 그 스프레드시트가 바로 마이그레이션 계획이 되었습니다.
두 번째 단계는 React 앱을 위한 코드모드 (codemod) 작업이었습니다. Heroicons와 Lucide는 임포트 경로와 컴포넌트 (component) 이름이 다르기 때문에 단일 정규 표현식 (regex)만으로는 충분하지 않았습니다. 저는 각 저장소를 순회하며 임포트 문을 파싱 (parse)하고 다시 작성하는 작은 Node 스크립트를 작성했습니다. Heroicons의 RocketLaunchIcon은 Phosphor의 RocketLaunch가 되었습니다. Lucide의 Rocket은 Phosphor의 RocketLaunch가 되었습니다. 명명 규칙 (naming)의 차이는 스크립트 내에 직접 작성한 매핑 (map) 테이블로 처리했습니다. 매핑 테이블을 포함해 코드 60줄 정도였습니다.
세 번째 단계는 Liquid 테마였습니다. 여기서는 코드모드를 사용할 수 없었습니다. 저는 40개의 Phosphor Fill 경로를 스니펫 (snippet) 폴더에 복사했습니다. 아이콘 하나당 하나의 파일로 만들고, icon-rocket-launch.liquid와 같이 이름을 붙였습니다. 그런 다음 각 테마 전체에서 찾기 및 바꾸기 (find-and-replace)를 수행했습니다. 기존의 인라인 SVG (inline SVG)는 제거하고, {% render 'icon-rocket-launch' %}를 넣었습니다. 이 과정은 가장 느린 부분이었으며 약 2시간 정도 걸렸는데, 기존 아이콘이 어떤 Phosphor 이름과 일치하는지 확인하기 위해 하나하나 눈으로 직접 대조해야 했기 때문입니다.
네 번째 단계는 바닐라 HTML (vanilla HTML) 페이지의 이모지 아이콘 (emoji-as-icons) 정리였습니다. 각 이모지를 Liquid 테마와 동일한 인라인 SVG 패턴으로 교체했습니다. 동일한 경로, 동일한 클래스 이름 (class names)을 사용했습니다. 아이콘당 한 번의 복사 및 붙여넣기 작업이 필요했습니다. 오후 6시가 되었을 때, 워크스페이스는 하나의 아이콘 시스템으로 통합되었습니다.
15개의 저장소, 플랫폼당 하나의 가중치, 그리고 RAXXO의 어떤 두 탭 사이에서도 불일치 제로. 저는 변경 사항과 그 이유를 설명하는 메시지와 함께 각 저장소를 개별적으로 커밋했습니다. 그 커밋 로그는 이전에 어떤 아이콘들이 있었는지 잊어버릴 경우를 대비한 저의 참조 자료가 되었습니다. 미래의 제가 현재의 저에게 고마워할 것입니다.
번들 수학 (Bundle Math): 최악의 경우 84KB에서 18KB로 감소
헤드라인에 적힌 숫자는 실제입니다. 마이그레이션 전, 최악의 경우 Next.js 앱은 84KB의 아이콘 코드를 임포트(import)하고 있었습니다. Heroicons solid와 Heroicons outline이 모두 클라이언트 번들(client bundles)로 불러와졌고, 몇몇 지연 임포트(lazy imports)가 트리 쉐이킹 (tree-shaking)을 방해했습니다. Lighthouse는 느린 연결 환경에서 이를 경고하고 있었습니다. 마이그레이션 후, 동일한 앱은 18KB의 Phosphor 아이콘 코드를 임포트합니다. 트리 쉐이킹 (tree-shaking)이 적용된 개별 컴포넌트 임포트 방식이며, 배럴 파일 (barrel files)을 사용하지 않습니다. 이는 최악의 경우 78%의 감소율입니다. 다른 Next.js 앱들은 이미 Lucide를 사용하여 31KB였으나, 18KB로 줄어들었습니다. 약 42%의 감소입니다. JS 번들 관련 모든 저장소의 평균을 내면, 감소 폭은 대략 60%입니다. 이것이 제가 제목에 적은 숫자이며, 스튜디오 전체를 아우르는 정직한 수치입니다.
Shopify 테마는 동일한 방식의 번들 비용이 발생하지 않습니다. 인라인 SVG 경로 (Inline SVG paths)는 페이지당 약간의 HTML 가중치를 추가하지만, JS나 폰트 파일, 추가적인 HTTP 요청은 발생하지 않습니다. Font Awesome을 제거한 것만으로도 두 개의 테마에서 76KB의 CSS를 절약했습니다. 그 가중치는 크리티컬 렌더링 경로 (critical render path)에서 사라졌습니다.
실제로 18KB를 달성하는 방법에 대한 몇 가지 세부 사항:
각 Phosphor 아이콘을 깊은 경로 (deep path)가 아닌 루트 패키지로부터 네임드 임포트 (named import)로 가져오세요. 해당 패키지의 ESM 빌드는 네임드 임포트가 트리 쉐이킹 (tree-shaking)이 올바르게 작동하도록 설정되어 있습니다. 와일드카드 (wildcard)를 사용하여 모듈 전체를 임포트하지 마세요. @phosphor-icons/react/dist/... 에서 직접 임포트하지 마세요. 깔끔한 경로가 지루하지만 확실한 경로입니다.
아이콘이 거의 사용되지 않는 매우 무거운 페이지의 경우, 동적 임포트 (dynamic import)가 잘 작동합니다.
const Settings = dynamic (() => import ( ' @phosphor-icons/react ' ). then ( m => m . Settings ) );
저는 설정 패널이 지연 마운트 (lazily mounted)되는 대시보드 화면에서 이 방식을 사용합니다. 이를 통해 초기 번들에서 제외된 아이콘당 추가로 0.4KB를 절약할 수 있습니다.
작은 수치이지만, 밀도가 높은 앱 전체를 놓고 보면 그 합은 상당합니다. 동일한 접근 방식이 Tailwind v4 tokens에 관한 제 노트에도 설명되어 있는데, 여기서도 지연 임포트 (lazy imports)를 통해 핵심 CSS를 가볍게 유지합니다. 결론은 이렇습니다. 하나의 아이콘 패밀리, 두 가지 웨이트 (weights), 15개의 저장소. 이것이 시스템의 전부입니다. Shopify 체크아웃의 로켓 아이콘과 Next.js 대시보드의 로켓 아이콘은 이제 형제처럼 느껴집니다. 환경이 다르기 때문에 서로 다른 웨이트를 착용하고 있을 뿐입니다. 하지만 스튜디오가 하나이기에 같은 손에서 만들어졌음을 알 수 있습니다. 번들 크기 절감은 측정 가능한 성과를 필요로 하는 제 뇌의 일부분에 이번 마이그레이션 (migration)을 정당화해 준 부수적인 효과였습니다. 최악의 앱에서 84KB를 18KB로 줄였고, 평균 60%를 절감했으며, 두 개의 테마에서 Font Awesome을 제거했습니다. 이 수치들은 실제이며 모든 방문자에게 전달됩니다. 하지만 더 깊은 승리는 Lighthouse 보고서에 담을 수 없는 것입니다. 여러 인터페이스 전반에 걸친 시각적 일관성 (visual consistency)이야말로 스튜디오를 단순한 사이드 프로젝트 폴더가 아닌 하나의 스튜디오처럼 느껴지게 만드는 요소입니다. Phosphor ( https://phosphoricons.com )는 제가 다루는 모든 플랫폼을 감당할 수 있을 만큼 충분한 웨이트를 가진 하나의 패밀리를 제공해 주었습니다. 저는 6가지 웨이트 중 2가지를 선택하여 규칙을 확정했습니다. 어려운 부분은 결정하는 것이었습니다. 마이그레이션은 오후 한때면 끝났습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기