아이콘 198개가 모든 화면에 실리던 이유.
한 줄 결론
아이콘을 이름 문자열로 꺼내 쓰는 객체 하나에 모아 두면, 번들러는 어떤 아이콘이 쓰이는지 알 수 없어서 198개를 전부 싣는다. 아이콘 하나를 모듈 하나로 나누고 사용처가 그 모듈을 직접 import하게 바꿔서 해결했다.
증상
Next.js 앱과 그 앱이 쓰는 디자인 시스템(DS) 패키지가 한 모노레포에 있었다. 아이콘은 DS가 제공했고, 사용처는 이렇게 썼다.
<Icon name="chevronLeft" variant="default" className="size-24" />
프로덕션 빌드를 bundle analyzer로 열어 보니 SVG 아이콘 198개가 청크 하나에 통째로 들어 있었다. 측정값은 다음과 같다.
| 항목 | 값 |
|---|---|
| 아이콘 청크 크기 | 132.6KB, gzip 22.2KB |
| 이 청크를 첫 로드에 받는 화면 | 299개 중 268개 |
| 화면 진입 JS에서 차지하는 비중 | 중앙값 15.4% |
한 화면이 실제로 쓰는 아이콘은 많아야 10~20개다. 그런데도 거의 모든 화면이 198개를 받고 있었고, 어떤 화면에서는 아이콘 청크가 그 화면의 코드보다 컸다.
왜 트리 셰이킹이 안 됐나
트리 셰이킹은 번들러가 import와 export를 정적으로 따라가서 쓰이지 않는 export를 결과물에서 빼는 일이다. 코드를 실행하지 않고 판단하므로, 소스만 보고 “이 모듈은 안 쓰인다”고 말할 수 있어야 뺄 수 있다.
기존 구조는 그 판단을 막고 있었다.
// iconMap.tsx (생성 파일)
import ChevronLeftDefault from './assets/chevronLeft.svg'
import ChevronRightSmall from './assets/chevronRight-small.svg'
// ... 198줄
const iconMap = {
chevronLeft: { default: ChevronLeftDefault },
chevronRight: { default: ChevronRightDefault, small: ChevronRightSmall },
// ...
} as const
// Icon.tsx
export const Icon = <T extends IconName>({ name, variant, className, ...props }: IconProps<T>) => {
const IconComponent = iconMap[name][variant]
return <IconComponent className={cn('icon fill-current', className)} {...props} />
}
번들러가 보기에 iconMap은 SVG 198개를 모두 참조하는 객체 하나다. Icon은 이 객체를 실행 시점에 정해지는 키(name, variant)로 읽는다. 어떤 키가 들어올지는 실행해 봐야 알기 때문에 번들러는 198개를 모두 남긴다. webpack, Turbopack, Rollup 모두 같다.
설정 문제도 아니었다. DS의 package.json은 이미 CSS 파일만 sideEffects에 적어, 나머지는 부수효과가 없다고 선언해 두었다. 트리 셰이킹이 안 된 원인은 코드 구조였다.
비용이 특정 화면이 아니라 모든 화면에 붙은 이유도 구조에 있다. DS의 Input, Chip, Button 같은 기본 컴포넌트 69개가 내부에서 Icon을 쓰고 있었다. 그래서 앱이 Input 하나만 import해도 198개가 함께 따라왔다.
고른 방법과 버린 방법
| 방법 | 동작 | 판단 |
|---|---|---|
| 아이콘 1개 = 모듈 1개, 직접 import | <ChevronLeftIcon />를 그 파일에서 import한다 | 채택. 표준 ESM 트리 셰이킹만으로 동작하고 번들러를 가리지 않는다. 대신 사용처 약 880곳을 모두 바꿔야 한다 |
SVG 스프라이트 + <use> | Icon API는 그대로 두고 내부만 <use href="/icons.svg#id">로 바꾼다 | 사용처를 하나도 안 바꿔도 된다. 하지만 아이콘을 JS에서 정적 파일로 옮길 뿐 전부 받는 것은 같고, SSR HTML에 아이콘이 없어 첫 페인트에서 한 박자 늦게 뜬다. 차선책 |
| 아이콘을 lazy import | 아이콘마다 React.lazy | 아이콘마다 요청과 Suspense 경계가 붙어 깜빡임과 요청 waterfall이 생긴다. 0.5KB짜리 리소스에는 맞지 않는다 |
<img src>나 CSS mask | 아이콘을 번들 밖 파일로 | 아이콘마다 요청이 생기고, mask는 여러 색 아이콘을 못 그린다 |
처음에 동료가 제안한 것은 lazy 처리였다. 원인이 런타임 키 접근이라는 진단은 같았다. 다만 lazy 처리는 번들러가 판단할 수 없는 구조를 그대로 두고 로딩만 미룬다. 그래서 번들러가 정적으로 판단할 수 있는 모양으로 바꾸는 쪽을 골랐다. lucide-react나 MUI 아이콘 패키지도 같은 구조다.
앱의 기본 빌더가 Turbopack이라 Babel 플러그인이나 번들러 전용 변환에 기대는 방법도 뺐다. Turbopack에는 그런 변환을 넣기 어렵다.
저장소에는 같은 목적으로 만든 시험 브랜치가 이미 있었다. 기존 컴포넌트를 아이콘별로 다시 내보내는 방식이었는데, Icon이 붙여 주던 icon fill-current 클래스가 빠지는 문제가 있어 이어받지 않았다. 이 클래스는 생각보다 중요하다. 다른 컴포넌트가 [&_.icon]:opacity-22 같은 하위(descendant) 셀렉터로 이 클래스를 참조하기 때문에, 빠지면 화면이 조용히 달라진다.
지울 수 없는 옛 API
계획을 세운 다음 날 제약이 하나 더 생겼다. 다른 웹 앱 하나가 이 DS 코드를 복사해 동기화하는 방식으로 쓰고 있었고, 그 앱은 당분간 Icon과 iconMap을 계속 쓸 예정이었다. 그래서 둘을 지우지 않고 @deprecated만 붙인 채 남겨 두고, 이 저장소의 사용처만 옮기기로 했다.
이 결정으로 이득이 생기는 시점이 달라졌다. 원래 계획은 아이콘을 하나 옮길 때마다 iconMap에서 그 엔트리를 지우는 것이었다. 그러면 절감이 조금씩 쌓이고, IconName 타입이 좁아져서 남은 사용처가 컴파일 에러로 드러난다. iconMap을 남기면 이 둘이 모두 사라진다. Icon을 import하는 파일이 하나라도 남아 있으면 198개가 그대로 실리고, 절감은 마지막 사용처를 옮기는 순간 한꺼번에 생긴다.
완료 판정 기준은 “이 저장소에서 Icon을 import하는 파일 0개, 그리고 빌드 결과에서 아이콘 묶음 청크가 사라진 것”으로 정했다.
다섯 단계
사용처를 한 번에 다 바꾸는 MR은 리뷰도 롤백도 어려워서 다섯 단계로 나눴다.
| 단계 | 한 일 | 이 순서인 이유 |
|---|---|---|
| 1 | 타입 검사를 우회하던 캐스팅 제거 | 뒤 단계가 타입 검사에 기대므로 구멍부터 막는다 |
| 2 | 아이콘별 컴포넌트 생성기 도입 | 새 모듈만 만들고 기존 경로는 그대로 둔다. 번들 변화가 없어 위험이 없다 |
| 3 | 사용 빈도 상위 5종을 codemod로 전환 | 전체 호출의 58%가 이 5종이다 |
| 4 | 나머지 전환, @deprecated, 신규 사용 금지 lint | Icon import가 0이 되는 단계. 여기서 이득이 생긴다 |
| 5 | 여러 색 아이콘(컬러 아이콘)도 같은 방식으로 전환 | 1~4단계의 생성기를 다시 쓴다 |
2단계 생성기와 3~4단계 codemod는 2편에서, 4~5단계에서 생긴 일과 결과는 3편에서 다룬다.
1단계: 타입 구멍부터 막기
Icon의 타입은 iconMap에서 파생된다. name은 keyof typeof iconMap이고, variant는 그 이름이 가진 variant 중 하나만 받는다. 리터럴로 쓰면 없는 조합을 컴파일러가 거부한다.
<Icon name="clock" variant="default" /> // TS2322: clock에는 default가 없다
문제는 이름을 변수로 넘기는 세 곳이었다. 이 세 곳은 as any나 as never로 타입 검사를 건너뛰고 있었다.
// 주문 유형 목록 상수에 이름과 variant를 문자열로 담고, 렌더할 때 캐스팅
{ key: 'afterMarketSingle', iconName: 'clock', iconVariant: 'outlined' }
<Icon name={item.iconName} variant={(item.iconVariant ?? 'default') as never} />
처음에는 판별 유니온으로 이름과 variant를 한 쌍으로 묶어 보려 했다.
type IconItem = {
[K in IconName]: { iconName: K; iconVariant: keyof IconMapType[K] }
}[IconName]
이 타입은 상수를 정의하는 쪽에서는 잘 동작했다. 하지만 렌더하는 쪽에서 깨졌다. IconProps<T>의 variant는 keyof IconMapType[T]인데, T가 유니온이면 keyof는 각 멤버 키의 교집합이 된다. 아이콘마다 variant 이름이 다르니 교집합은 never다. 구조 분해, 스프레드, 제네릭 함수 중 어느 모양으로 써도 캐스팅 없이는 렌더할 수 없었다.
그래서 이름과 variant를 문자열로 넘기지 않고, 둘이 리터럴로 확정되는 상수 정의 자리에서 아이콘 엘리먼트를 만들어 두게 바꿨다.
interface OrderTypeItem {
key: OrderTypeKey
icon: ReactNode
}
{ key: 'afterMarketSingle', icon: <Icon name="clock" variant="outlined" className="size-24" /> }
렌더 쪽에서 아이콘을 그리던 콜백과 훅 파라미터가 통째로 사라졌다. 그리고 숨어 있던 버그가 하나 드러났다. clock에는 default variant가 없다. 그런데 iconVariant가 비어 있으면 기존 코드의 ?? 'default' 폴백이 바로 그 default를 넘기게 돼 있었다. as never가 이 문제를 덮고 있었다.
나머지 한 곳은 props가 icon?: string이라 as any로 넘기던 OCR 결과 카드였다. 호출부가 실제로 넘기는 값은 두 개뿐이어서 'lock' | 'eye'로 좁혔다. Extract<IconName, 'lock' | 'eye'>는 쓰지 않았다. iconMap에서 엔트리가 사라지면 이 타입은 에러 없이 조용히 좁아져서 지키려던 검사가 작동하지 않기 때문이다.
검증은 두 가지로 했다. 일부러 "clockk"나 없는 variant를 넣어 컴파일 에러가 나는지 확인했고, 화면은 바뀌기 전과 후를 같은 경로로 캡처해 픽셀을 비교했다. 아이콘 영역은 완전히 같았다.
1단계를 마친 날, 앞에서 말한 “지울 수 없는 옛 API” 제약이 생겼다. 엔트리를 하나씩 지우는 계획은 접었지만, 캐스팅을 없앤 덕분에 뒤에 나오는 codemod와 lint 규칙은 타입이 정확한 코드 위에서 돌 수 있었다.
다음 편
2편은 아이콘 모듈을 만드는 생성기와 사용처를 바꾸는 codemod 이야기다. 두 도구가 어긋나지 않게 만든 방법과, 생성 결과를 매번 같게 만드느라 고친 것들을 다룬다.