목록으로

아이콘 4,843개 중 실제로 쓰는 건 200개도 안 됐다

#monorepo#nextjs#lighthouse

Font Awesome 아이콘 폰트 전체를 그대로 번들에 넣고 있었는데, 실제 사용하는 아이콘은 200개도 안 된다는 걸 알고 서브셋 빌드 파이프라인을 직접 만든 이야기. 정규식 CSS 파싱이 왜 위험한지, 런타임에 조립되는 아이콘 이름을 어떻게 정적으로 추적했는지, 줄인 폰트도 로드 순서 때문에 왜 여전히 느렸는지를 반말 회고체로 정리했다.


category
frontend
type
Post
slug
fontawesome-icon-subset-pipeline
summary
Font Awesome 아이콘 폰트 전체를 그대로 번들에 넣고 있었는데, 실제 사용하는 아이콘은 200개도 안 된다는 걸 알고 서브셋 빌드 파이프라인을 직접 만든 이야기. 정규식 CSS 파싱이 왜 위험한지, 런타임에 조립되는 아이콘 이름을 어떻게 정적으로 추적했는지, 줄인 폰트도 로드 순서 때문에 왜 여전히 느렸는지를 반말 회고체로 정리했다.
date
Feb 19, 2025
updatedAt
Sep 17, 2026
tags
monorepo
nextjs
lighthouse
thumbnail

페이로드의 42%가 아이콘이었다

어느 날 Lighthouse 감사 결과를 받아 들고 표 하나를 한참 들여다봤다. "가장 큰 병목" 칸에 적힌 게 자바스크립트도, 이미지도 아니었다.
파일
크기
fa-light-300.woff2
455 KB
fa-regular-400.woff2
417 KB
fa-solid-900.woff2
347 KB
fa-brands-400.woff2
117 KB
합계
1,336 KB — 전체 페이로드의 42%
아이콘 폰트였다. Font Awesome Pro를 쓰고 있었고, 그 전체 세트를 통짜로 싣고 있었다. 원본 자산을 세어보면 아이콘 4,843개, CSS 290KB에 woff2 4종 1,333KB, 합쳐서 약 1.6MB.
근데 우리 프론트엔드 모노레포(서비스 본체 + Project B + 백오피스 2종)가 실제로 쓰는 아이콘은 200개 미만이었다. 나머지 96%는 다운로드되고 파싱되고 캐시 자리만 차지할 뿐 화면엔 한 번도 안 나온다.
게다가 아이콘 폰트는 font-display: block이라 폰트가 도착할 때까지 아이콘 자리가 그냥 비어 있다. 1.3MB짜리 폰트 네 개가 전부 fetchPriority="high"로 preload되면서 서로 대역폭을 다투고 있었으니 모바일에서 첫 화면이 늦는 것도 당연했다.

왜 그냥 자르면 안 되냐면

서브셋에는 고약한 실패 모드가 하나 있다.
서브셋에 없는 아이콘은 에러 없이 그냥 안 보인다.
빌드는 성공한다. 타입 체크도 통과한다. 콘솔에도 아무것도 안 찍힌다. 누가 새 버튼에 fa-gear를 넣었는데 서브셋 목록에 gear가 없으면, 그 버튼은 텍스트만 덩그러니 남고 아이콘 자리는 빈 칸이 된다. QA가 눈으로 발견하기 전까진 아무도 모른다.
그래서 요구사항을 세 개로 잡았다.
  1. 정확도 — 저장소에서 실제로 렌더될 수 있는 아이콘을 빠짐없이 찾는다. 애매하면 더 싣는다.
  1. 자동화 — 재생성을 사람이 기억 안 해도 되게 빌드에 묶는다.
  1. 로드 경로 — 줄인 폰트가 첫 화면을 막지 않게 preload 가능한 형태로 배포한다.

전체 구조

디자인시스템 패키지(Package A) 안의 스크립트 하나가 다섯 단계를 한다.
  1. 원본 CSS를 파싱해서 아이콘 이름 → 코드포인트 표를 만든다
  1. 소스 전체를 2-pass로 스캔해서 variant별 사용 아이콘을 뽑는다
  1. variant별로 woff2를 서브셋한다
  1. 미사용 규칙을 제거한 CSS 두 벌을 만든다
  1. 산출물을 패키지 배포 경로로 복사한다
실행은 명령어 한 줄이다. 근데 이 명령을 사람이 직접 칠 일은 거의 없다. prebuild에 걸려 있어서 패키지를 빌드할 때마다 자동으로 돈다. 재료가 저장소 안에 있어서 네트워크가 필요 없고, 1초 안에 끝난다.

1단계: CSS를 읽는다 — 정규식이 뒤통수 치는 지점

Font Awesome CSS에서 아이콘 하나는 이렇게 생겼다.
.fa-gear, .fa-cog { --fa: "\f013"; }
이름(별칭 포함)이랑 코드포인트를 뽑으면 된다. 처음엔 당연히 정규식으로 블록을 잘랐다. \{[^}]*\} 이런 식으로.
근데 Font Awesome엔 이런 아이콘이 있다.
.fa-bracket-curly-right { --fa: "}"; }
값 자체가 닫는 중괄호다. 정규식은 따옴표 안의 }에서 블록이 끝났다고 착각하고, 그 뒤의 "; }가 다음 블록의 선택자로 읽히면서 파일 전체 블록 경계가 밀린다. 이 상태로 미사용 규칙을 지우면 CSS가 그냥 깨진다.
그래서 따옴표랑 이스케이프를 인식하면서 문자 단위로 걷는 작은 스캐너를 짰다. 따옴표 안에서는 {, }를 안 세고, 백슬래시를 만나면 두 칸 건너뛰고, 중첩 깊이를 세서 @media { .x { } } 같은 블록도 최상위 단위로 잡는다. 제너레이터로 만들어서 파싱·가지치기·@font-face 제거에 같은 걸 재사용했다.
블록이 "아이콘 정의"인지 판정할 땐 .fa-stack .fa-inverse 같은 자손 선택자나 유틸리티 규칙이 섞인 블록은 아이콘 정의로 안 봤다. "순수 아이콘 선택자 목록"일 때만 아이콘 정의로 인정해야, 나중에 아이콘이 아닌 규칙을 실수로 지우는 걸 막을 수 있다.

2단계: 저장소에서 "쓰는 아이콘"을 찾는다

여기가 진짜 본론이다. 제일 단순한 방법은 소스 전체에서 fa-xxx 문자열을 찾는 거다. <i class="fa-solid fa-heart"> 같은 레거시 마크업이면 그걸로 충분하다.
근데 우리는 아이콘 컴포넌트로 아이콘을 쓴다. fa-{variant} fa-{name}을 런타임에 조립한다.
<Icon name="heart" /> <Icon variant="light" name="bell" />
여기까진 괜찮다. 문제는 이름이 데이터로 흘러오는 경우다.
const MENU: { label: string; icon: ClassicIconName }[] = [ { label: '설정', icon: 'gear' }, { label: '알림', icon: 'bell' }, ]; <Icon name={item.icon} />
소스 어디에도 fa-gear라는 문자열은 없다. 이런 코드가 저장소 곳곳에 있었다.

단서: 타입

무기가 하나 있었다. 아이콘 이름 union 타입. 원본 CSS에서 이름 목록을 뽑아 타입을 생성하는 스크립트가 이미 있었고, 아이콘을 다루는 코드는 대부분 이 타입을 달고 있었다.
type ClassicIconName = 'gear' | 'bell' | 'heart' | /* ... */; type IconSpec = { variant?: 'solid' | 'regular' | 'light' | 'brands'; name: ClassicIconName };
TypeScript 컴파일러 API로 타입을 제대로 해석하는 방법도 있는데, 1초 안에 끝나야 하는 prebuild에 넣기엔 너무 무겁다. 그래서 타입 표기를 텍스트 단서로 쓰는 2-pass 스캔을 택했다.
1차 스캔에서는 icon: ClassicIconName, symbol?: IconSpec 같은 선언을 저장소 전체에서 찾아서 "아이콘을 담는 프로퍼티 이름"(icon, symbol 등)을 모은다. 타입이 다른 파일에 선언돼 있어도 상관없다. 1차 스캔이 전체 파일을 먼저 훑기 때문이다.
name은 일부러 뺐다. IconSpec의 정의 자체가 name: ClassicIconName이라서, 넣는 순간 저장소의 모든 name: '...'(사용자 이름, 파일 이름, 라우트 이름…)이 아이콘 후보로 끌려온다.
2차 스캔에서는 파일마다 variant별로 이름을 뽑는다. 규칙이 여러 개인데 대략 이렇다.
  • className 문자열("fa-regular fa-clock") → 같은 리터럴 안의 variant 토큰 사용, 없으면 미확정
  • <Icon variant="light" name="bell" /> → 리터럴이면 확정, variant={expr}나 spread면 미확정, variant 자체가 없으면 기본값 solid
  • { variant: 'regular', name: 'gear' } 같은 오브젝트 리터럴 → 확정
  • 1차 스캔에서 찾은 프로퍼티의 리터럴(icon: 'clock') → 미확정
  • 타입 인자 자리의 테이블(Record<Mode, ClassicIconName>) → 미확정, 값 위치 문자열만 수집
"같은 문자열 리터럴 안"을 정확히 판단하려면 사실 JS 파서가 필요한데, 여기서는 직전 따옴표를 리터럴 시작점으로 근사했다. 어긋날 수도 있지만, 어긋나도 variant가 미확정이 될 뿐 이름 자체는 놓치지 않는다. 이 원칙이 스크립트 전체를 관통한다.
<Icon> 태그를 통째로 잘라내는 부분도 까다로웠다. JSX 속성값 안에는 >가 흔하다(onClick={() => a > b}, 제네릭 <T>). 첫 >에서 자르면 태그가 잘린다. 그래서 따옴표와 중괄호 깊이를 추적해서 깊이 0의 >에서만 태그가 끝났다고 판단했다.
태그 밖의 name=은 아예 안 본다. <meta name="viewport">, <input name="email"> 같은 게 섞이면 "CSS에 없는 이름" 경고 목록이 쓰레기로 가득 차서, 진짜 오타를 찾는 용도로 못 쓰게 된다.
스캔 범위도 신경 썼다. 숨김 디렉터리는 빼되 스토리북 디렉터리는 포함했다(스토리에서 쓰는 아이콘도 문서 사이트에 렌더되니까). 테스트 파일은 런타임에 렌더 안 되니까 뺐다. 그리고 원본 아이콘 폰트가 들어있는 vendor 디렉터리를 스캔 대상에서 빼먹으면 서브셋이 통째로 무의미해진다. 원본 CSS엔 아이콘이 사실상 전부 들어 있어서, 그걸 스캔하는 순간 "저장소가 모든 아이콘을 쓴다"는 결론이 나버린다.

정적 분석이 끝내 못 잡는 것

clsx('fa-regular', `fa-angle-${direction}`)
템플릿 문자열 조립은 정적 분석으로 알 수가 없다. 이런 곳은 그냥 명시 등록 목록에 출처 파일과 조립 코드를 주석으로 남기고 수동으로 넣었다.

3단계: variant별로 자른다

처음 버전은 수집한 이름 전체를 폰트 4개에 똑같이 적용했다. 그러면 이런 일이 생긴다.
light variant를 딱 한 곳에서 한 아이콘에만 써도, light 폰트에는 수집된 200개 전부의 글리프가 실린다.
light 폰트가 원본에서 제일 큰 파일(455KB)이었으니까 이건 그냥 넘어갈 수 없었다. 그래서 variant별로 나눴다.
variant를 모르는 이름은 어디에 넣을지가 문제였는데, 기준은 실무 관찰이었다. 아이콘 컴포넌트의 기본 variant는 solid고, 동적으로 넘어오는 variant는 실제 코드에서 solid·regular뿐이었다. light·brands는 항상 호출부에 리터럴로 적혀 있어서 추적이 실패한 적이 없었다. 그래서 미확정 이름은 solid·regular 두 파일에 중복으로 실었다. 글리프 몇 개 더 싣는 비용으로 "조용히 사라지는 아이콘"을 막는 거래다.
폰트 자르는 부분 자체는 정말 한 줄이다. subsetFont(original, text, { targetFormat: 'woff2' }) — text는 남길 글리프의 코드포인트를 이어 붙인 문자열이다. 어떤 variant에 해당 아이콘이 하나도 없으면 글리프 없는 껍데기 폰트가 남는데, @font-face는 남아 있어도 그 폰트를 쓰는 요소가 페이지에 없으면 브라우저가 아예 다운로드를 안 하니까 비용이 없다.

4단계: CSS를 가지치기한다

스캐너로 블록을 순회하면서 아이콘 블록만 골라 안 쓰는 이름을 걸러냈다. 세 가지가 중요했다.
  1. 비아이콘 규칙 보존 — @font-face, 애니메이션, 유틸리티 클래스는 원본 그대로 둔다. 동작이 바뀌면 안 된다.
  1. 별칭 축약 — .fa-gear, .fa-cog에서 gear만 쓰면 .fa-gear만 남긴다.
  1. 경로랑 @charset 정리 — 원본은 CSS랑 웹폰트가 같은 폴더인데 산출물은 폴더가 나뉘니까 상대 경로를 고치고, @charset은 파일 맨 앞에서만 유효한데 이 CSS는 앱 CSS에 인라인되니까 제거했다.
생성된 CSS 맨 위에는 "이 파일은 스크립트로 생성됩니다. 직접 수정하지 마세요" 헤더를 박아뒀다.

크기를 줄여도 느렸다: 로드 순서 문제

서브셋으로 바이트를 줄인 뒤에도 문제가 하나 남았다. 아이콘이 여전히 늦게 보였다.
앱이 CSS를 import하면 그 안의 폰트 경로는 번들러를 거쳐 해시가 붙은 정적 경로로 바뀐다. 해시 덕분에 장기 캐시는 좋아지는데, 빌드 전에는 경로를 모르니까 <link rel="preload">를 걸 수가 없다. 그리고 font-display: block이니까 폰트 요청은 레이아웃 CSS를 다 받아 파싱한 뒤에야 시작되고, 그동안 아이콘은 투명하게 비어 있다.
선택지가 두 개였다. 앱마다 public/에 woff2를 복사하고 고정 경로로 preload하거나, next/font/local로 디자인시스템 패키지의 woff2를 선언하는 거. 후자를 택했다. woff2는 패키지 한 곳에만 있고, 프레임워크가 preload를 자동으로 붙여준다.
근데 함정이 하나 있었다. 프레임워크의 폰트 기능이 @font-face를 직접 생성하는데, CSS에도 @font-face가 있으면 같은 폰트를 두 번 받는다. 그래서 CSS를 두 벌 만들었다. 하나는 @font-face 포함된 기본형(그냥 import만 하는 앱용), 하나는 @font-face를 빼고 family를 CSS 변수로 바꾼 버전(폰트 로더랑 반드시 짝을 맞춰 써야 하는 버전). CSS 변수로 치환할 때 원래 family 이름을 fallback으로 남겨뒀다. 앱이 변수 클래스 붙이는 걸 깜빡해도 조용히 깨지지 않고 원래 이름으로 떨어지게.
CDN 방식을 쓰는 Project B 쪽에서는 preload 경합도 따로 정리했다. 원래 woff2 4개를 전부 fetchPriority="high"로 preload하고 있었는데, 가장 많이 쓰는 solid 폰트만 high로 남기고 나머지 3개는 preload를 뺐다. 필요할 때 브라우저가 알아서 받게.

운영 규율: "조용히 깨지지 않게" 만드는 것들

스크립트보다 오래 남을 건 이 규칙들이라고 생각한다.
매 빌드마다 돈다. 재생성을 사람 기억에 맡기면 언젠가 빠뜨리고, 그때 조용히 깨진다. 네트워크가 필요 없고 1초 안에 끝나니까 빌드에 묶지 않을 이유가 없었다.
원본은 저장소 안에 둔다. 예전엔 빌드할 때마다 외부 페이지에서 원본을 받았는데, 그러면 그 페이지가 빌드타임 단일 실패 지점이 된다. 원본을 저장소에 넣으니까 오프라인에서도 재생성할 수 있고, 그래야 "매 빌드마다 돈다"가 가능해진다. 배포되는 자산이 아니라 빌드 재료라서, 배포 경로가 아니라 별도 vendor 경로에 뒀다. 원본 갱신은 버전이 오를 때만 별도 명령으로 한다.
타입 생성기랑 서브셋 생성기는 같은 원본을 본다. 만약 타입은 최신 원격본에서, 서브셋은 로컬 원본에서 만든다면? "타입엔 있지만 서브셋이 만들 수 없는 아이콘"이 생긴다. 그 아이콘은 컴파일을 통과하고 화면에서만 사라진다. 제일 찾기 어려운 종류의 버그다.
산출물은 커밋한다. 배포 결과물에 들어가야 하기도 하고, 무엇보다 PR diff에서 아이콘 증감이 눈에 보인다. "이 PR로 light 글리프 3개가 늘었다"를 리뷰어가 눈으로 확인할 수 있다.
실패는 시끄럽게. 사용 아이콘을 하나도 못 찾으면 빌드를 그냥 실패시킨다. 스캔 경로가 깨졌다는 신호니까. 그리고 콘솔 리포트를 매 빌드마다 찍어서, variant 미확정 목록이랑 "CSS에 없는 이름"(사실상 오타 탐지기) 경고를 보여준다. <Icon> 밖의 name=을 안 보는 이유가 바로 이 경고 목록을 깨끗하게 유지하기 위해서였다.

결과

서브셋 자체의 before/after는 아직 리포트로 정리 중이라 정확한 수치는 비워둔다. 이 작업이 포함된 번들 최적화 전체 결과는 이렇다.
지표
개선 전
개선 후
변화
아이콘 자산
4,843개 · 1,623KB
사용 200개 미만으로 서브셋
-
전 페이지 공통 청크
444KB
252KB
-43%
주요 페이지 First Load JS
585KB
380KB
-35%
INP (RUM p75)
280ms
168ms
-40%
LCP (RUM p75)
2,312ms
1,543ms
-33%
공통 청크·First Load JS·INP·LCP는 서브셋 말고도 차트 라이브러리 동적 로딩, 환경 분리, 패키지 출력 형태 개선, 스트리밍 렌더링 등이 같이 만든 결과다. 아이콘 서브셋만의 기여로 읽으면 안 된다. 재생성은 1초 이내, 네트워크 불필요, 빌드마다 자동으로 돈다.

정리

  1. 자르는 건 쉽고, 뭘 남길지 아는 게 어렵다. 폰트 서브셋 자체는 라이브러리 한 줄이었다. 나머지는 전부 "사용처 추적"이었다.
  1. 정규식으로 CSS를 파싱하지 마라. 값이 }인 아이콘 하나가 파일 전체를 깨뜨린다.
  1. 타입은 정적 분석의 단서다. 컴파일러 API 없이도 타입 표기를 텍스트로 따라가면 간접 참조를 대부분 잡을 수 있다.
  1. 확신이 없으면 더 실어라. 놓친 아이콘은 조용히 사라지지만, 더 실은 글리프는 몇 KB일 뿐이다.
  1. 조용한 실패는 빌드에 묶어라. 사람 기억에 맡긴 재생성은 반드시 한 번은 빠진다.
  1. 빌드 재료는 저장소 안에. 외부 페이지는 빌드의 단일 실패 지점이다.
  1. 같은 원본에서 파생하라. 타입이랑 산출물이 다른 원본을 보면 "컴파일은 되는데 안 보이는" 버그가 생긴다.
  1. 바이트랑 순서는 다른 문제다. 줄인 폰트도 preload 못 하면 여전히 느리다. 폰트 로더 기능과 @font-face 없는 CSS로 풀었다.
남은 한계도 있다. 문자열 조합으로 만든 아이콘 이름은 여전히 사람이 명시 목록에 등록해야 한다. 이름 union 타입으로 사용처를 강제하고 확정 안 되면 여러 variant로 퍼뜨리는 안전장치를 뒀지만 완전 자동은 아니다. 그리고 번들 크기는 기능이 늘면 다시 커지니까, CI에서 크기 상한을 검사하는 장치가 다음 과제다.

© 2024. Dahoon06. All rights reserved.