예상 밖 비용, 번들 검사가 놓친 회귀, 전환 결과.
지난 편
생성기가 SVG 하나마다 컴포넌트 하나를 만들고, codemod가 같은 이름 규칙으로 사용처를 바꿨다. 상위 5종 508건을 먼저 옮기고 나머지를 이어서 옮겼다.
한 줄 결론
전환 도중에는 작은 SVG가 화면별 청크에 복사돼 빌드가 조금 커졌고, 전환을 마치자 거의 모든 화면이 받던 gzip 약 24KB가 사라졌다. 옛 Icon을 다시 쓰지 못하게 하는 방법으로는 번들 검사 대신 import 금지 lint를 골랐다.
작은 SVG는 공유 청크로 빠지지 않는다
상위 5종을 옮긴 뒤 번들을 다시 쟀다. 예상은 “아직 Icon이 남아 있으니 공유 아이콘 청크는 그대로이고, 나머지도 그대로”였다. 공유 청크는 예상대로 거의 변하지 않았다(gzip 25.2KB에서 25.4KB). 그런데 다른 곳이 커졌다.
| 항목 | 전 | 후 |
|---|---|---|
| 빌드 안의 SVG 모듈 인스턴스 | 198개 | 850개 |
| 크기가 커진 라우트 | - | 332개 중 200개 |
| 라우트당 증가분 | - | gzip 중앙값 0.34KB, 최대 0.97KB |
원인은 webpack의 청크 분리 규칙이었다. 여러 라우트가 같은 모듈을 쓰면 webpack은 그 모듈을 공유 청크로 빼낸다. 단, 빼낼 모듈 크기의 합이 splitChunks.minSize보다 작으면 공유 청크를 만들지 않고 각 라우트 청크 안에 복사해 넣는다. 공유 청크를 하나 더 만들면 요청이 하나 더 생기는데, 그 요청 비용이 복사 비용보다 크다고 보기 때문이다.
아이콘 SVG는 하나에 0.5KB 정도다. 딥 import로 바뀐 아이콘은 이 기준에 못 미쳐서 쓰는 라우트마다 한 벌씩 들어갔다. 동시에 Icon이 아직 남아 있으니 공유 청크도 그대로였다. 전환 도중에는 두 비용을 함께 내는 기간이 생긴 것이다.
되돌리지 않았다. 라우트당 0.3KB 정도를 더 내는 대신, 전환을 마치면 이 청크를 받던 라우트에서 공유 청크 약 25KB가 빠진다. 다만 이 비용을 미리 계산하지 못했다는 점은 MR 본문에 그대로 적었다.
“회귀 없음”이라는 보고가 틀렸던 일
이 작업은 AI 에이전트(Claude Code)와 같이 했다. 3단계를 마쳤을 때 에이전트는 시각 회귀 테스트 결과를 근거로 “회귀 없음”이라고 보고했다. “UI 비교는 어떻게 했냐”고 다시 물었더니 그 보고가 틀렸다는 것이 드러났다.
- DS의 시각 회귀 테스트 46개는 모두 실패하고 있었다. 테스트에 하드코딩된 스토리 id가 실제 Storybook id와 달라서, 픽셀을 비교하기도 전에 스토리를 못 찾고 있었다.
- 비교 기준이 되는 스냅샷 이미지는 저장소에 한 장도 없었다.
- 바뀌기 전 코드에서도 같은 46개가 같은 이유로 실패했다. 그래서 “실패 목록이 같다”는 사실이 “회귀 없음”으로 잘못 보고됐다.
테스트가 통과했는지가 아니라 무엇을 비교했는지를 확인해야 했다. 다시 확인할 때는 앱을 시뮬레이터에 띄워 화면 20개를 바뀌기 전과 후로 캡처해 비교했다. 20개 중 정상적으로 그려진 10개 화면에서 아이콘 차이는 0이었다. 아이콘 하나는 그것이 나오는 화면에 진입하지 못해 확인하지 못했다.
번들 검사가 회귀를 놓친 이유
4단계에서 Icon 사용처가 0이 되면, 누군가 Icon을 다시 쓰는 순간 198개가 돌아온다. 그래서 “청크 하나에 SVG가 100개 넘게 들어 있으면 CI를 실패시키는” 번들 검사를 먼저 생각했다.
넣기 전에 시험했다. 한 파일만 Icon으로 되돌리고 빌드해 봤더니 가장 큰 SVG 청크에 든 아이콘이 93개였다. 임계값 100을 그냥 통과했다.
| 상태 | 가장 큰 SVG 청크 | 빌드 전체 SVG 수 |
|---|---|---|
Icon 사용처 167곳 | 190개 | 760개 |
Icon 사용처 1곳(회귀 재현) | 93개 | 1,961개 |
Icon 사용처 0곳 | 42개 | 1,756개 |
사용처가 많으면 iconMap이 공유 청크 하나에 모인다. 사용처가 하나뿐이면 그 경로의 청크 여러 개로 쪼개져 들어가서, 청크 하나만 보면 작아 보인다. 빌드 전체 SVG 수는 오히려 늘었다. 쪼개지면서 중복이 생겼기 때문이다. 이 때문에 “한 청크에 N개 이상” 같은 단일 임계값은 회귀가 작을수록 놓친다.
그래서 빌드 결과 대신 소스를 막기로 했다. oxlint의 no-restricted-imports로 Icon과 iconMap을 import하면 에러가 나게 했다. 임계값을 관리할 필요가 없고, 빌드를 기다리지 않아도 편집기에서 바로 에러가 보인다. 정의 파일과, 다른 앱에 다시 내보내는 배럴 파일 한 곳만 예외로 뒀다.
oxlint override는 합쳐지지 않는다
설정을 넣다가 함정을 하나 발견했다. 설정에는 이미 특정 폴더용 override가 여러 개 있었고, 그중 몇 개가 no-restricted-imports를 쓰고 있었다. oxlint는 뒤에 오는 override가 같은 규칙을 설정하면 앞의 설정과 합치지 않고 통째로 대체한다. 시험 파일을 두 폴더에 만들어 lint를 돌려 보니, 한 폴더에서는 전역 경고가 나오고 다른 폴더에서는 아무것도 안 나왔다.
맨 뒤에 블록 하나를 붙이면 앞의 폴더별 설정을 덮어쓴다. 그래서 이 규칙을 쓰는 기존 블록마다 금지 경로를 덧붙였다. 폴더마다 시험 파일로 에러가 나는지 확인했다.
이 방식은 다음 단계에서 실제로 한 번 문제가 됐다. 컬러 아이콘을 전환하면서 import 금지 규칙을 추가할 때, TradingView 차트를 감싼 폴더의 override에만 덧붙이는 것을 빠뜨렸다. 리뷰에서 발견해 고쳤다. 앞으로 새 override를 추가하는 사람도 같은 실수를 할 수 있다. 이 부분은 설정 구조상 남은 위험이다.
@deprecated는 언제 붙이나
@deprecated는 3단계에서 붙이지 않고 4단계로 미뤘다. 이유는 두 가지였다.
- 3단계 시점에는 아직 바꾸지 않은
<Icon>이 약 370건이었다. 그때 붙이면 그 370곳에 취소선이 뜨고, DS 코드를 동기화해 쓰는 다른 앱에도 같은 표시가 간다. - 범위를 좁혀
iconMap의 필드마다@deprecated를 다는 방법은 쓸 수 없었다. 시험해 보니 TypeScript language service에서 사용처에 표시되지 않았다.
또 @deprecated는 표시만 할 뿐 막지는 못한다. 4단계에서는 표시(@deprecated)와 차단(lint)을 같이 넣었다.
컬러 아이콘도 같은 방식으로
여러 색을 쓰는 컬러 아이콘 172개는 카테고리별 맵 9개로 나뉘어 있었다. 맵 하나를 쓰면 그 카테고리 아이콘이 전부 실리는 구조다. 생성기의 공통 부분을 떼어 내 컬러 아이콘용 생성기를 만들고 같은 codemod 방식으로 옮겼다. 가장 큰 카테고리 청크(gzip 9.9KB)가 사라졌다.
이 단계에서는 AI 코드 리뷰 봇이 실제 결함을 하나 찾았다. 기존 컬러 아이콘 컴포넌트는 알 수 없는 값을 받으면 null을 그렸는데, 새 구조로 옮기면서 그 처리가 빠졌다. 이 저장소 안에서는 타입 검사가 막아 주지만, DS를 동기화해 쓰는 다른 앱은 타입 검사를 거치지 않을 수 있다. 그쪽에서 모르는 값이 들어오면 Element type is invalid로 화면이 깨진다. 동적으로 쓰는 곳 6곳을 다시 확인했고, 처리가 빠진 한 곳에 null을 그리는 처리를 되살렸다.
결과
4단계를 마친 시점에 Turbopack 빌드로 잰 결과다.
| 구분 | 항목 | 전 | 후 |
|---|---|---|---|
| 화면 | 아이콘 묶음 청크 | 93.1KB, gzip 24.5KB | 없음 |
| 화면 | 한 화면이 받는 아이콘 | 198개 고정 | 청크 기준 중앙값 2개, p90 6개, 최대 42개 |
| 빌드 전체 | static 청크 gzip 합계 | 10.67MB | 10.97MB (+2.8%) |
| 소스 | Icon을 import하는 파일 | 197개 | 0개 |
| 운영 | 신규 사용 차단 | 없음 | lint error |
webpack 빌드에서도 아이콘 묶음 청크(132.6KB, gzip 22.2KB)가 사라진 것을 확인했다.
컬러 아이콘까지 끝낸 뒤에는 develop 브랜치를 webpack으로 다시 빌드해 처음 측정한 시점과 비교했다. 화면 하나가 받는 SVG 수는 중앙값 202개에서 23개로 줄었다. 화면 진입 JS 중앙값은 151.9KB에서 116.3KB로 줄었다. 다만 그 사이 다른 변경도 들어갔으므로, 이 작업만의 효과는 아이콘 묶음 청크 크기인 gzip 22.2KB 안팎으로 본다. 사용자가 체감하는 로딩 시간은 재지 않았다.
빌드 전체가 늘었는데도 이득인 이유
공유 청크에 한 번만 있던 아이콘이 화면별 청크로 흩어지면서 여러 번 들어가게 됐고, 빌드 전체는 gzip 0.30MB 늘었다. 그래도 이득으로 본 근거는 세 가지다.
- 사용자가 받는 단위는 빌드 전체가 아니라 화면 하나다. 늘어난 0.30MB는 서버에 보관하는 용량이고, 한 사용자가 그만큼 더 받는 것이 아니다.
- 고정비가 사라졌다. 전에는 아이콘을 하나 쓰든 열 개를 쓰든 gzip 24.5KB를 냈다. 아이콘 하나가 gzip 약 127B이므로, 지금은 아이콘 두 개를 쓰는 화면이 약 250B를 낸다. 가장 많이 쓰는 화면(42개)도 약 5.3KB다.
- 첫 진입에서 받는 양이 가장 많이 줄어든다. 전에는 첫 진입에서 24.5KB를 받고 이후에는 캐시를 썼다. 지금은 화면마다 조금씩 받는다. 아이콘이 하나도 겹치지 않는 화면을 60개 넘게 방문해야 누적량이 예전 고정비를 넘는다. 실제로는 화면끼리 아이콘이 많이 겹치므로 그 지점에 도달하기 어렵다.
아이콘 하나당 127B는 예전 묶음 청크 크기를 198로 나눈 추정치다. 아이콘마다 path 복잡도가 달라 실제 값에는 편차가 있다.
남은 일
Icon과 iconMap 파일은 아직 남아 있다. DS를 동기화해 쓰는 다른 앱에 <Icon> 사용처가 432곳 있고, 그 앱을 옮긴 뒤에 지울 수 있다. 이 작업은 별도 이슈로 분리했고 일정은 정해지지 않았다. 생성기는 그때까지 iconMap을 계속 만든다.
다음 편
구조를 바꾸고 나니 아이콘을 추가하는 과정이 눈에 들어왔다. 디자이너가 SVG를 내보내고 개발자가 손으로 고쳐 넣는 과정이었다. 4편은 디자인 파일을 원본으로 삼아 아이콘을 동기화하게 만든 이야기다.