아이콘 시스템 통합 — 설계
Status: 설계 확정 (2026-08-10). 구현 진행 중 — 6장 작업 순서의 0~4번만 이 브랜치에서 한다. Branch:
feat/icon-consolidationoffdevelop감사 리포트: https://claude.ai/code/artifact/8b9bef1b-fa8b-440b-a2a7-38d6ce6703af 중복 60개 그룹의 실제 SVG 소스와 파일·줄번호 위치가 전부 들어 있다.5~6번(S3 이관)은 이 브랜치 밖이다. 0~4번만으로 통합은 완결되고 화면은 정상 동작한다. 5장은 그때가 되면 꺼내 쓰라고 조사 결과를 남겨 둔 것이지 지금 하는 일이 아니다.
1. 목표
저장소 전역에 흩어진 인라인 <svg> 1,016곳을 없애고 아이콘을 그리는 경로를 전역 매직 $icon() 하나로 통일한다. 통일 후에는 색상·크기·두께를 호출 시점에 조절할 수 있어야 하고, 같은 일이 다시 벌어지지 않도록 막는 장치까지 건다. S3 이관은 이 통합이 끝난 뒤 얹는다.
2. 범위
In
- 인라인
<svg>1,016곳 →$icon()호출로 전량 교체 - 아이콘 도형 294개를 155개 이름으로 통합 (미등록 67개 신규 등록 포함)
- 색상·크기·두께 커스텀 축 제공
- 전역 아이콘 매직
$icon등록 — 앱 11개 + 공용 템플릿 전부 - 인라인
<svg>pre-commit 차단 + 아이콘 목록 페이지
Out
- S3 업로드와 fetch 전환 (작업 순서 6번). 파일 추출(5번)까지는 했고, 버킷에 올려
$icon을 fetch 로 바꾸는 것만 남았다. - 아이콘 아트워크를 hereby 디자인 세트로 다시 그리는 작업. 이번에는 기존 도형 중 정본을 고르기만 한다. 파일 이름이 확정되므로 나중에 덮어써도 코드 변경이 없다.
- 브랜드 자산
logo-*— CLAUDE.md BI 규칙 소관 - 디자이너가 S3에 직접 업로드하는 Lambda 파이프라인
3. 현황 (실측)
3.1 규모
SVG 요소 출현 1,016회 (224개 파일)
고유 도형 298개
− 브랜드 자산 12개 (logo-* 7개 이름 · 18회 — 범위 밖)
− iOS 앱아이콘 자산 2개 (AppIcon.icon/Assets — UI 아이콘이 아니다)
− 유령 아이콘 2개 (두 상태를 x-show 로 겹쳐 둔 토글 — 아래 참조)
= 아이콘 도형 282개
고유 이름 155개 ← 만들 파일 수
통합으로 감축 127개298개를 하나씩 눈으로 식별해 이름을 붙인 결과다. 96개는 icons.ts 레지스트리의 기존 이름을 지문 매칭으로 물려받았고, 나머지 202개는 렌더링해서 직접 판별했다. 미식별 0개.
렌더링 방식이 결과를 좌우한다. 1차 식별은 173개였으나 10개가 오분류였다. 검증용 렌더에서 class 속성을 지운 것이 원인이다. stroke-white가 사라지면서 검은 원 안의 흰 글리프가 보이지 않았고, 성공(체크)·오류(X)·안내(i) 세 아이콘이 "채워진 원" 하나로 묶였다. 로딩 스피너 4개도 ic-circle로 잘못 묶였다. 2차는 원본 class를 유지하고 hereby 토큰 18개와 실제로 쓰이는 유틸리티 클래스 23개를 정의한 뒤 60px로 렌더해 진행했고, 중복 60개 그룹을 전수 재검증했다.
픽셀 자동 판정도 폐기했다. 동그라미 테두리가 면적을 지배하면 안쪽 글리프 차이가 묻혀 정보·경고·시계·물음표를 한 덩어리로 묶고(39개 그룹 중 10개가 오판), 반대로 굵기만 다른 같은 휴지통은 다르다고 판정했다(놓친 중복 83쌍). 160이라는 수치는 사람이 붙인 이름에서 나온 것이나, 최종 이름은 icons.ts 등록본을 정본으로 삼아 92개가 기존 이름으로 확정됐다(6절 참조).
추출 로직도 한 번 고쳤다. 정규식이 HTML 주석 안의 <svg> 글자를 매치해 뒤쪽 마크업까지 삼킨 경우가 5건 있었다. 제외하면서 출현 1,021 → 1,016, 고유 도형 299 → 298로 정정됐다.
3.2 왜 이렇게 됐나 — 원인 3개
| 원인 | 규모 | 근거 |
|---|---|---|
| 쓸 수 있었는데 안 씀 | 645곳 (63%) | 헬퍼도 있고 레지스트리에도 있는데 <svg>를 직접 씀 |
| 레지스트리에 없었음 | 262곳 (26%) | 등록된 적 없는 아이콘 |
| 앱에 헬퍼가 없음 | 109곳 (11%) | general-affairs·inventory·landing·management-dashboard·module-manager |
63%가 압도적이다. apps/admin 275곳, packages/components 201곳은 헬퍼도 있고 그 아이콘도 레지스트리에 있는데 인라인을 썼다. 레지스트리를 만든 패키지 자신이 자기 레지스트리를 안 쓴다.
몰라서가 아니라 볼 방법이 없고 막는 것이 없어서다. icons.ts에 뭐가 들어 있는지 확인하려면 97개 문자열을 눈으로 훑는 수밖에 없고, 문서도 갤러리도 검색도 없다. 인라인 <svg>를 써도 lint도 pre-commit도 리뷰도 통과한다. 모달과 다이얼로그는 check:modals/check:dialogs로 막고 있으나 아이콘에는 그런 장치가 없다.
여기에 기계적인 이유가 하나 더 붙는다 (0번 구현 중 확인). 헬퍼 icon()은 앱 최상위 x-data에 달린 컴포넌트 메서드라 중첩된 x-data 안에서는 스코프가 끊겨 호출할 수 없다. 모달·위저드·x-for 안의 컴포넌트가 전부 여기 해당한다. 그러니 63% 중 상당수는 "안 쓴" 것이 아니라 그 자리에서는 못 쓴 것이다. 0번을 전역 매직으로 바꾼 근거가 이것이다 — 0번 항목 참고.
시점도 겹쳤다. 인라인 사용은 2026-05-08에 시작됐고 icons.ts는 2026-05-21에, ic-chevron-right 등록은 2026-07-08에 이뤄졌다. 등록 이전에 쌓인 것은 아무도 되돌아가 고치지 않았다.
3.3 "중복"이라는 말은 부정확하다
이름이 겹치는 것은 60개 이름에 198개 도형이다. 같은 크기로 렌더해 픽셀까지 동일한 것은 17개뿐, 나머지 181개는 실제로 다르게 보인다. 정확히는 같은 아이콘의 통제되지 않은 변형이다.
변형 자체는 정당할 수 있으나 의도된 세트가 아니라는 근거가 셋이다.
- 60개 그룹 중 다수가 같은 앱 안에서 여러 변형을 혼용한다. 맥락별로 고른 것이 아니다.
- 좌표계도 굵기도 같은데
d만 다른 것들이 있다.ic-x-mark의 u006·u019·u045는 셋 다 24 좌표계 굵기 2이고 렌더 결과가 동일하다(u045만path대신line2개). - 좌표계가 12·14·16·20·24로 다섯 종이다. 크기는 CSS가 잡으므로 좌표계는 크기와 무관하다. 다섯 종인 이유는 출처가 달라서다(Heroicons 24 stroke 218개, hereby DS 16 fill 40개).
3.4 정본 선택이 필요한 범위
| 구분 | 그룹 | 사용량 | 처리 |
|---|---|---|---|
| 자명함 (1위가 2위의 3배 이상) | 24 | 619회 | 최다 사용 자동 채택 |
| 무난함 (1.5~3배) | 16 | 90회 | 자동 채택 |
| 박빙·동점 | 20 | 100회 | 사람 판단 필요 |
사용량 809회 중 709회가 자동으로 정해진다. 이 표는 "최다 사용" 기준일 때의 분포이며, 최종 규칙(등록본 우선)에서는 사람 판단이 0개가 된다. 아래 6절 「정본 선택 규칙 — 확정」 참조.
3.5 기존 인프라
| 항목 | 값 |
|---|---|
| static S3 버킷 | dev-hereby-works-okbest-static (stg: stg-hereby-works-okbest-static) |
| CloudFront | E7N6ALW4I22QE (stg: EMSIWW1MBFYMK) |
| 공개 도메인 | https://okbest-static.dev.hereby.me |
| CI AWS 인증 | aws-actions/configure-aws-credentials@v6.0.0 (OIDC) |
| 업로드 경로 | .github/workflows/dev-frontend.yaml — brand/, styles/ sync 중 |
| 정본 폴더 | packages/static/icons/ (이미 존재, .svg 11개) |
icons.ts 상단 주석이 이 이관을 이미 계획해두고 있다.
Temporary location — SVG files at
packages/static/icons/*.svgwill gradually replace these inline strings.
4. 설계
4.1 커스텀 3축
| 축 | 방식 | 근거 |
|---|---|---|
| 색상 | 부모의 color / Tailwind text-* 상속 | 155/160이 이미 currentColor |
| 크기 | 래퍼의 CSS 클래스 또는 opts.size | viewBox는 좌표계지 크기가 아님 |
| 두께 | opts.stroke → 인라인 style | 프레젠테이션 속성은 특이도 0이라 CSS/style이 이김 |
<span class="w-4 h-4 text-red-500" x-html="$icon('ic-close')"></span>색상과 크기는 래퍼가 잡으므로 0번에서 등록한 $icon(name) 한 인자로 이미 두 축이 열린다. 남는 것은 두께 하나다.
$icon(name: string, opts?: { stroke?: number }): string;두께만 인자인 이유 — 래퍼로는 닿지 않는다. stroke-width 는 상속되는 속성이지만 아이콘의 <svg> 가 자기 값을 프레젠테이션 속성으로 들고 있어 부모가 물려줄 수 없다. 실측으로 래퍼에 stroke-width:3 을 줘도 1.5px 그대로였다. 전역 CSS 로 여는 방법도 안 된다 — 의도적으로 다른 값을 쓰는 아이콘(1.2 / 1.8 / 2)이 전부 뭉개진다. 그래서 주입 시점에 문자열을 고쳐 얹는다.
루트에만 얹으면 부족하다. 굵기를 <path> 쪽에 든 아이콘이 있는데 자식의 프레젠테이션 속성이 루트 상속을 이기기 때문이다. 안쪽에 적힌 값까지 같이 바꾸고, 루트에 없으면 새로 붙인다.
나머지 세 축은 래퍼가 이미 다 한다 — 실제 브라우저에서 확인했다.
| 축 | 방법 | 확인 |
|---|---|---|
| 크기 | w-3 h-3 … w-12 h-12 | 12×12 → 48×48 |
| 색상 | text-* → currentColor 상속 | 161개 전부 상속 |
| 방향 | rotate-90 / 180 / 270 | rotate: 180deg 적용 |
| 애니메이션 | animate-spin 등 | animation-name: spin |
색 상속이 안 되던 아이콘이 하나 있었다. ic-exclamation 은 fill/stroke 속성이 아예 없어서 SVG 기본값인 검정으로 렌더됐다 — text-red-500 안에 넣어도 검정이었다. currentColor 를 붙여 고쳤고, 161개 전부 상속하는 것을 확인했다.
Tailwind 스캔 주의. text-orange-500 처럼 그 앱의 소스에 한 번도 안 쓰인 클래스는 빌드 CSS 에 생성되지 않아 색이 안 먹는다. 아이콘만의 제약이 아니라 모든 요소에 해당하는 Tailwind 기본 동작이지만, 아이콘은 색을 바깥에서 받으므로 특히 눈에 띈다.
size·class 옵션은 만들지 않는다. 4.3에서 보듯 크기·색·애니메이션은 전부 래퍼 <span>이 받으므로 같은 일을 하는 통로가 둘이 되기만 한다.
4.2 CSS로는 크기를 통일할 수 없다
전부 동일한 24px 슬롯에 넣고 실제 렌더 결과를 측정한 값이다.
| 이름 | 좌표계 | 굵기 | 실제 렌더 크기 |
|---|---|---|---|
ic-x-mark (133회) | 16 | 1.5 | 17.2 × 17.2 |
| (26회) | 24 | 2 | 14.0 × 14.0 |
| (10회) | 16 | 1.6 | 14.4 × 14.4 |
| (3회) | 12 | 1.3 | 14.6 × 14.6 |
같은 슬롯에서 도형이 14.0px에서 17.2px까지 23% 벌어진다. ic-chevron-down도 23%, ic-check·ic-trash는 14%다. 선 굵기도 좌표계 단위라 같이 확대되어 24px 기준 1.80px~2.60px로 44% 벌어진다. viewBox가 크기가 아니라 좌표계이기 때문이며, CSS 한 줄로 덮을 수 있는 문제가 아니다. 파일을 합쳐야 한다.
4.3 교체 시 옮겨야 하는 것 — 여기가 실무 관문
인라인 <svg>가 자기 안에 들고 있는 속성들이다. 그냥 지우고 $icon()으로 바꾸면 깨진다.
| 항목 | 규모 | 안 옮기면 |
|---|---|---|
크기 클래스 (w-4 h-4 등) | 368곳 | 아이콘 크기가 전부 깨짐 |
색상 클래스 (text-*, stroke-white) | 143곳 | 색이 사라짐 |
애니메이션 (animate-spin 등) | 50곳 | 스피너가 멈춤 |
Alpine 동적 바인딩 (:style 등) | 20곳 | 트리 화살표가 안 돎 |
role="img" / aria-label | 4곳 | 접근성 손실 |
전부 래퍼 <span>으로 옮긴다.
<!-- 이전 -->
<svg class="w-4 h-4 text-neutral-300 animate-spin" viewBox="0 0 24 24">…</svg>
<!-- 이후 -->
<span class="w-4 h-4 text-neutral-300 animate-spin" x-html="$icon('ic-spinner')"></span>크기 클래스 분포는 w-4 209곳, w-6 47곳, w-5 36곳, w-3.5 35곳이다. 파일 하나에 담을 수 없으므로 교체할 때 한 곳씩 옮겨야 하며, 이것이 이 작업의 실제 공수다.
aria-hidden="true"는 787곳에 붙어 있는데 이건 아이콘 파일 안에 그대로 둔다. 의미를 갖는 role="img" + aria-label 4곳만 래퍼로 옮긴다.
래퍼를 새로 추가하지는 않는다. design-system.css에 > svg 직계 자식 선택자가 52개 있어(.ds-btn--icon > svg, .ds-label > svg 등) 기존 슬롯 안에 <svg>가 직접 들어가야 한다. 이미 래퍼가 있는 곳은 그 래퍼를 쓰고, 없는 곳만 <span class="ds-ico">를 만든다.
.ds-ico 를 정본으로 올렸다 (0번 중 확인 · 처리 완료)
이 래퍼는 이미 존재하고, > svg 직계 자식 문제까지 포함해 우리가 필요한 일을 정확히 한다. 그런데 정의가 apps/hr-system/src/styles/main.css 한 곳에만 있다. design-system.css에는 없다. 반면 마크업에서 .ds-ico를 쓰는 앱은 hr-system(21개 파일)과 drive(5개 파일) 둘이다. 빌드 산출물로 확인했다 — apps/hr-system/dist의 CSS 에는 .ds-ico가 있고 apps/drive/dist에는 아예 없다. CLAUDE.md 가 경고하는 복사 함정과 같은 형태다.
drive 가 지금 깨져 보이지 않는 이유는 따로 있다. 정의가 없으면 <span>은 display:inline 그대로라 w-4 h-4가 적용되지 않고, 안에 주입된 <svg>는 width/height 속성이 없어 컨테이너를 가득 채운다. 400px 폭 컨테이너에서 재보면 이렇다.
.ds-ico 정의 | 부모 | span | 주입된 svg |
|---|---|---|---|
| 있음 (hr-system) | 아무거나 | 20×20 | 20×20 |
| 없음 (drive) | display:block | 400×18 | 400×400 |
| 없음 (drive) | display:flex | 20×20 | 20×20 |
drive 의 5곳이 전부 우연히 flex 부모 안에 있다(flex items-center, 컨텍스트 메뉴 버튼). flex 아이템이 되면서 w-4 h-4가 되살아나 정상으로 보인다. 즉 지금 터진 버그가 아니라 조건부로 가려져 있는 함정이다. 3번에서 .ds-ico를 flex 가 아닌 자리에 하나라도 쓰는 순간 25배 크기로 터진다.
그래서 3번 전에 구조 부분만 design-system.css로 올렸다.
정본으로 올림 .ds-ico { display:inline-flex; … } · .ds-ico > svg { width:100%; height:100% }
.ds-btn--icon > .ds-ico 크기 스케일 (12/16/20/24)
hr-system 유지 .ds-ico > svg, .ds-ico > svg * { stroke-width: 2 } · .ds-ico--x-match
.ds-btn--icon.ds-btn--md > .ds-ico { 18px } (DS 기본 20px 의 앱 레벨 조정)stroke-width: 2는 같이 올리면 안 된다. 그건 시트 화면의 아이콘 굵기를 통일하려고 hr-system 이 내린 결정이지 전역 규격이 아니다. 정본으로 올리면 8개 앱의 모든 아이콘 굵기가 한 번에 바뀐다 — 4.1에서 "두께는 요청했을 때만 얹는다"고 정한 것과 정면으로 어긋난다.
결과 확인. 빌드 산출물에서 10개 앱 전부 .ds-ico 를 갖게 됐고, 시트 전용 굵기 규칙은 hr-system 에만 남았다. md 는 hr-system 만 18px, 나머지는 DS 기본 20px 로 갈린다(같은 특이도라 @import 뒤에 오는 앱 규칙이 소스 순서로 이긴다).
화면은 안 바뀐다. drive 의 5곳은 원래 flex 부모 덕에 20×20 으로 렌더되고 있었는데, 래퍼가 붙어도 flex:none + w-5 h-5 + > svg { 100% } 로 같은 20×20 이다 — 실측으로 두 경우가 동일한 것을 확인했다. 달라지는 건 앞으로 flex 가 아닌 자리에 써도 안 터진다는 것뿐이다.
4.4 그라디언트 id 충돌 — 교체로 새로 생기는 버그
linearGradient를 가진 도형이 17곳 있다. 지금은 인라인이라 각자 다른 id를 쓰지만, 레지스트리의 같은 문자열을 두 번 주입하면 id가 겹친다. SVG 그라디언트 id는 문서 전역이라, 한 화면에 hereby 로고 둘이 들어가는 순간 브라우저가 먼저 나온 정의를 써서 엉뚱한 색으로 칠한다.
주입 시점에 id를 인스턴스마다 유일하게 재작성해야 한다. 지금은 안 터지고 교체하면 터지는 버그이므로 기존 회귀 테스트로는 잡히지 않는다.
4.5 이름 규칙 — 확정
채움 변형은 나눈다. 단, 실제로 속이 찬 5개만이다.
분리 O ic-warning · ic-clock · ic-user · ic-message · ic-users
→ ic-warning / ic-warning-fill 형태로 각각 별도 파일
분리 X ic-info · ic-plus · ic-chevron-down · ic-chevron-left-double
ic-import · ic-export · ic-sparkles · ic-dots-horizontal기계적으로 stroke 유무만 보면 14개가 걸리지만, 렌더링해 보면 8개는 <path fill>로 그렸을 뿐 결과가 외곽선이다. ic-plus는 둘 다 그냥 +이고 ic-chevron-down은 둘 다 ∨다. 나누면 의미 없는 파일만 늘어난다. stroke 속성이 아니라 렌더 결과로 판단해야 한다.
브랜드 자산은 이 시스템 범위 밖이다. logo-* 7개(도형 12개, 18회)는 아이콘이 아니라 BI 자산이므로 CLAUDE.md의 브랜드 규칙(마스터/패밀리, en/ko, 로크업/워드마크 구분)을 따른다. 아이콘 파이프라인에 섞지 않는다.
정체 불명 2건은 그대로 둔다. u099는 렌더해도 아무것도 나오지 않고 u275는 주황 그라디언트 사각형이다. 합쳐서 3회이므로 ic-unknown으로 등록해 두고, 담당자가 확인되면 이름을 바로잡는다.
4.6 방향 파생 — 빌드 타임 회전
같은 계열끼리 회전시켜 픽셀 비교한 결과다.
| 관계 | 회전 | 차이 |
|---|---|---|
ic-chevron-left = ic-chevron-right | 180° | 0% |
ic-arrow-down = ic-arrow-up | 180° | 0% |
ic-chevron-down | 90° | 1.49% |
ic-chevron-up | 270° | 3.32% |
ic-chevron-double-* | 90/270° | 18.3% |
ic-upload-line | 180° | 8.66% |
0%인 쌍이 둘 있다는 것이 핵심이다. 나머지가 어긋나는 것은 회전이 안 되기 때문이 아니라 지금 그림들이 제각각이기 때문이며, 정본을 통일하면 전부 0%가 된다. ic-download-line / ic-upload-line은 예외로, 180° 돌리면 밑줄까지 올라가므로 별도 파일이어야 한다.
런타임 CSS 회전은 쓰지 않는다. 이미 회전 애니메이션을 쓰는 곳이 20곳 있어 충돌한다. 디자이너는 ic-chevron-right.svg 하나만 그리고, 빌드 스크립트가 나머지 3방향을 회전시켜 파일로 만든다. 런타임에는 평범한 파일 4개라 transform 충돌이 없고, 디자이너가 관리할 원본은 160개가 아니라 153개가 된다.
4.7 재발 방지 — 이게 없으면 6개월 뒤 원상복구
인라인 <svg> pre-commit 차단. 63%를 만든 원인이 "막는 게 없었다"이므로 이것이 1순위다. scripts/check-new-modals.mjs와 같은 방식으로 새로 추가되는 줄에 <svg가 있으면 막는다. 정당한 예외(브랜드 자산 인라인, 이메일 HTML)는 ds-icon-exempt: <reason> 주석으로 통과시킨다.
아이콘 목록 페이지. 차단을 걸면 개발자가 "그럼 뭘 써야 하지"에서 막히므로 갈 곳이 있어야 한다. docs/ko/design-system/에 페이지를 추가해 이름 검색과 $icon('...') 복사를 제공한다. 지금 만든 감사 리포트가 그대로 이 페이지의 원형이 된다.
이름 오타 검사. getIcon()은 없는 이름에 빈 문자열을 돌려주므로 오타가 나면 아이콘이 조용히 사라진다 — 에러도 로그도 없다. 1,016곳을 옮기는 동안 가장 현실적인 위험이 이것이다. 그래서 목록 페이지와 같이 $icon('...') 호출부의 이름이 레지스트리에 있는지 보는 스크립트를 붙인다(iconNames()가 그 목록을 준다). 런타임 console.warn 보다 빌드 타임 검사가 낫다 — 경고는 화면을 열어 봐야 보이지만 스크립트는 커밋에서 막힌다.
전역 매직 등록은 0번에서 끝났다. 차단을 켜기 전에 아이콘을 쓸 경로가 먼저 있어야 한다는 순서 자체는 그대로다.
5. 후속 — S3 이관
위 통합이 끝나면 icons.ts의 160개를 파일로 꺼내 S3에 올린다. 통합 없이 S3만 해서는 의미가 없고, S3 없이도 통합은 완성된다. S3가 추가로 주는 것은 두 가지뿐이다.
- 번들 크기 감축 —
ICONS가 객체 리터럴이라 번들러가 키 단위 트리셰이킹을 못 한다. 아이콘 3개만 쓰는 module-manager도 전부를 지고 있다. 앱마다 gzip 17.8 KB. - 배포 없는 즉시 반영 — 아이콘을 고쳤을 때 앱 11개를 재빌드·재배포하지 않는다.
5.1 전달 방식
https://okbest-static.dev.hereby.me/icons/ic-x-mark.svg개별 파일 방식. 매니페스트를 두지 않는다. 화면이 실제로 쓰는 아이콘만 받고, 하나를 고치면 그 파일만 다시 받는다. 느려지면 그때 매니페스트로 전환하며, $icon() 시그니처가 같으므로 호출부는 그대로다.
<img src="...svg">는 쓸 수 없다. replaced element라 바깥 CSS가 내부로 들어가지 못해 색상 상속이 끊기고 > svg 규칙 52개가 한꺼번에 죽는다. 외부 스프라이트(<use href="https://…#id">) 도 브라우저가 cross-origin을 차단한다. fetch로 받아 DOM에 진짜 <svg>로 심는다.
const cache = new Map<string, string>();
export function icon(name: IconName, opts?: IconOpts): string {
if (cache.has(name)) return apply(cache.get(name)!, opts);
fetch(`${STATIC_URL}/icons/${name}.svg`)
.then((r) => r.text())
.then((svg) => { cache.set(name, prepare(svg)); Alpine.store("icons").tick++; });
return ""; // 아직 없으면 빈 값, 도착하면 Alpine 반응성으로 다시 그린다
}STATIC_URL은 테넌트별 staticUrl(.github/deploy/*-resolve.json)에서 온다. 로컬 개발에서는 packages/static/icons/를 그대로 서빙해 S3 없이 돌게 한다.
5.2 SVGO — 이중 설정 (검증 완료)
97개를 96×96(2배 해상도)으로 렌더링해 픽셀 비교한 결과다.
| 설정 | 크기 | gzip | 픽셀 일치 |
|---|---|---|---|
| 원본 | 56.9 KB | 13.9 KB | 기준 |
| 기본 압축 | 42.4 KB | 8.5 KB | 94 / 97 |
| 정밀도 상향(전체) | 48.0 KB | 11.2 KB | 97 / 97 |
| 채택 — 로고만 무손실 | 48.7 KB | 10.4 KB | 97 / 97 |
기본 압축에서 BI 심볼 3개가 깨졌다. SVGO가 그라디언트 좌표를 소수점 3자리로 반올림해 정지점이 밀렸고 최대 63/255 만큼 색이 달라졌다(offset="0.389423" → ".389"). 육안으로는 구분되지 않았고 픽셀 비교가 아니었으면 통과했을 차이다. CLAUDE.md가 BI 그라디언트를 규격으로 못박은 자산이므로 무손실로 처리한다.
// scripts/icons/svgo.config.mjs — 일반 아이콘
export default {
multipass: true,
plugins: [
{ name: "preset-default", params: { overrides: { cleanupIds: { minify: false } } } },
{ name: "prefixIds", params: { delim: "-" } },
"removeDimensions",
],
};
// scripts/icons/svgo.logo.mjs — logo-*.svg (브랜드 규격 보존)
export default {
plugins: [
{ name: "preset-default", params: { overrides: {
cleanupIds: { minify: false },
cleanupNumericValues: false, // 좌표 반올림 금지
convertPathData: false, convertTransform: false,
convertColors: false, mergePaths: false,
} } },
{ name: "prefixIds", params: { delim: "-" } },
"removeDimensions",
],
};cleanupIds를 기본값으로 두면 BI 로고 6개가 전부 id="a"가 된다. 지금은 안 터지는 버그를 SVGO가 확정적으로 만들어 내는 셈이다. prefixIds가 파일 간 충돌을, 런타임 prepare()가 같은 아이콘 다중 주입을 막는다. removeDimensions는 루트 width/height를 떼어 크기 조절이 균일하게 먹게 한다.
5.3 CI
.github/workflows/dev-frontend.yaml의 static sync 블록(현재 brand/, styles/)에 icons/를 추가한다. SVGO는 그 앞 단계에서 돈다. 새 인프라는 없다.
.svg 커밋 → push → CI가 SVGO → static 버킷 sync → CloudFront 무효화디자이너가 S3에 직접 올리는 Lambda 방식은 나중에 얹는다. 지금은 원본이 git에 남아 복구·리뷰가 되는 쪽이 낫고, Lambda는 같은 버킷에 결과를 쓰면 자기를 다시 호출하는 무한 루프와 동시 업로드 경쟁 조건을 함께 들여온다.
5.4 타입
$icon()의 오타를 컴파일 타임에 잡으려면 이름 목록이 필요하다. 빌드 스크립트가 생성한다.
// packages/components/src/icon-names.ts (자동 생성, 커밋 대상)
export type IconName = "ic-x-mark" | "ic-chevron-right" | "logo-hereby" | /* … */;기존 아이콘을 교체하는 경우는 배포 없이 즉시 반영된다. 새 아이콘을 코드에서 처음 부를 때만 타입 파일 커밋이 필요하다.
6. 작업 순서
0. 전역 아이콘 매직 $icon 등록 ✅ 완료 — 앱 11개 + 공용 템플릿 전부
1. 정본 확정 + icons.ts 정리 ✅ 완료 — 97개 → 161개(아이콘 155 + 브랜드 6)
· 규칙으로 전량 자동 확정, 사람 판단 0
· 이름 이관 29곳 (chevron-* 접두사 통일 + ic-border-all → ic-matrix)
2. 앱 하나로 시범 교체 + 픽셀 회귀 ✅ 완료 — management-dashboard 46곳
3. 나머지 교체 ✅ 완료 — 호출부 895곳 · 인라인 14곳만 남음
4. 인라인 <svg> pre-commit 차단 ✅ / 목록 페이지 (남음)
───────────────────────────────────────────── 여기까지가 이 브랜치. S3 없이 완성됨
5. icons.ts → packages/static/icons/*.svg 추출 + SVGO ✅ 완료 — 161개
6. CI sync + S3 CORS + $icon 을 fetch 방식으로 교체 ← 이 브랜치 밖2번을 건너뛰면 368곳 크기 깨짐과 70곳 애니메이션 정지를 한꺼번에 만난다. 3번에서 packages/components를 먼저 건드리면 앱 8개가 동시에 영향받는다.
5번 — 파일 추출과 SVGO 설정 재측정
scripts/icons/build.mjs 가 icons.ts 를 읽어 packages/static/icons/*.svg 161개를 만든다. pnpm icons:build 로 생성하고 pnpm icons:check 로 어긋났는지 본다.
.svg 는 손으로 고치는 파일이 아니다. 지금 정본은 icons.ts 하나이고 파일들은 파생 산출물이다. 이 구분이 없으면 같은 아이콘이 두 곳에 있으면서 서로 어긋난다 — 2026-05 부터 석 달간 정확히 그 상태였다(파일 11개가 있었지만 코드는 아무도 안 봤고, 화면은 인라인 마크업을 따로 그렸다). 6번에서 방향이 뒤집혀 .svg 가 정본이 된다.
icons.ts 파일 자체는 남는다. 6번이 걷어내는 것은 그 안의 SVG 문자열 97.9 KB 뿐이고, $icon 매직 등록·getIcon·타입 선언 6.7 KB 는 계속 거기 산다. 바뀌는 것은 getIcon 이 번들된 문자열을 찾느냐 URL 을 받아 오느냐다.
5.2 의 설정은 틀렸다 — 다시 쟀다
문서에 「채택 — 로고만 무손실 · 97/97 픽셀 일치」로 적혀 있었으나, 새 정본 161개로 다시 재니 24px 에서 32개가 눈에 띄게 달라졌다. 원인은 convertPathData 의 makeArcs 다 — 베지에 곡선을 호(A 명령)로 바꾸는데 근사라서 형태가 미세하게 달라진다. ic-team 은 <g transform="translate(1 2)"> 가 좌표로 접히고 곡선이 호로 바뀌면서 24px 에서 28픽셀이 어긋났다.
다섯 설정을 돌려 실사용 크기(24px)에서 잰 결과다. 「차이」는 채널값이 32 이상 벌어진 픽셀 수다.
| 설정 | 크기 | gzip | 24px 차이 | 최다 |
|---|---|---|---|---|
| 기본 | 71.5 KB | 14.5 KB | 32 / 161 | ic-team 28px |
| 호 변환만 끔 — 채택 | 74.6 KB | 16.1 KB | 14 / 161 | ic-eraser 7px |
| + transform 도 끔 | 74.6 KB | 16.1 KB | 14 / 161 | 위와 동일 |
| + 정밀도 5 | 84.4 KB | 20.3 KB | 11 / 161 | 10 KB 더 쓰고 3개 개선 |
| 경로를 손 안 댐 | 97.4 KB | 24.3 KB | 1 / 161 | 압축 이득 0 |
최적화는 표현을 줄이는 것이지 그림을 다시 그리는 것이 아니다. 그래서 makeArcs 만 끈다. 경로를 아예 안 건드리면 gzip 이 원본과 같아져 압축할 이유가 사라진다. 남은 14개는 전부 대각선 가장자리의 서브픽셀 안티에일리어싱이고 최악이 2,304픽셀 중 7픽셀(0.3%)이다.
최종 — 161개 · 97.6 KB → 80.4 KB (gzip 24.3 → 17.8 KB). 비교표: https://claude.ai/code/artifact/579914b9-d86a-4f87-89b0-f48ea6b1dab4
교훈. 5.2 의 「97/97」은 옛 97개 세트에 대한 수치였고 판정 기준도 남아 있지 않았다. 설정을 그대로 물려쓰지 않고 새 세트로 다시 잰 것이 맞았다 — 검증 수치는 대상이 바뀌면 다시 재야 한다.
4번 — pre-commit 차단
scripts/check-icons.mjs 를 check:modals·check:dialogs 와 같은 형태로 만들어 .husky/pre-commit 에 차단 모드로 걸었다. 두 가지를 보는데 범위가 서로 다르다.
| 검사 | 범위 | 왜 |
|---|---|---|
inline-svg — 화면 파일에 <svg 를 새로 그리는 것 | 추가된 줄만 | 모달 검사와 같은 이유 |
unknown-icon — $icon('…') 이 없는 이름을 부르는 것 | 전체 파일 | 조용히 깨지고, 개명 한 번에 남의 파일이 전부 깨진다 |
unknown-icon 이 이 스크립트의 핵심이다. getIcon() 은 없는 이름에 빈 문자열을 돌려주므로 오타 하나면 아이콘이 에러도 로그도 없이 사라진다. 게다가 레지스트리에서 이름 하나를 고치면 손대지 않은 파일의 호출부가 전부 그 상태가 되므로, 추가된 줄만 봐서는 못 잡는다. 그래서 전수로 본다.
만들자마자 실제 버그를 잡았다. 1번에서 ic-id-badge → ic-id-card 로 개명했는데 apps/admin/src/app.ts 의 icon: 설정 두 곳이 남아 있었다. 3번의 호출부 점검은 getIcon(...) 같은 호출 형태만 봤기 때문에 설정 필드에 문자열로 들어간 이름을 놓쳤다. 검사는 두 형태를 모두 본다.
모달 검사와 달리 레거시가 없다. 3번에서 전부 옮겼으므로 pnpm check:icons:audit 이 0 이다. 남아 있던 9곳(브랜드 자산 3 · 서명 애니메이션 2 · 차트 생성기 3 · :d 바인딩 1)에는 ds-icon-exempt: <사유> 를 달아 왜 예외인지 코드에 남겼다. 통과시키려고 단 것이 아니라 그 문자열이 리뷰 대상이 되게 하려는 것이다.
시험. 인라인 <svg> 한 줄과 없는 이름 하나를 심은 파일을 스테이징해 둘 다 막히는 것과, ds-icon-exempt 를 단 <svg> 와 정상 호출은 통과하는 것을 확인했다(종료코드 1 / 0).
목록 페이지는 아직 남았다. 차단만 있고 갈 곳이 없으면 "그럼 뭘 써야 하지" 에서 막힌다. 지금은 실패 메시지가 사용법과 설계 문서를 가리키는 것으로 대신하고 있다.
3번 — 전량 교체에서 잡힌 것
앱 전체를 먼저 바꾸고 packages/components 를 마지막에 했다. 최종적으로 $icon() 호출부 895곳이고 인라인 <svg> 는 14곳만 남았다 — 브랜드 자산 3, 랜딩 서명 애니메이션 2, 도넛·막대 차트 생성기 3, 런타임 :d 바인딩 1, 나머지는 주석 안의 예시다.
회귀 결과. 853곳을 조상 체인 안에서 재서 822곳이 크기·자리 그대로다. 3곳은 이 화면 폭에서 렌더되지 않아 재지 못했고, 나머지 28곳은 아래 넷 중 하나다 — 터지거나 사라진 곳은 없다.
잡힌 것 넷.
- TS 템플릿 문자열 안의
x-html은 동작하지 않는다.apps/employee-app/src/nav-config.ts의 하단 탭 아이콘 8개가 여기 걸렸다. 이 문자열은bottom-nav.html이x-html로 주입하는데 Alpine 은 주입된 내용의 지시문을 다시 훑지 않는다. 그대로 뒀으면 빈 span 이 되어 탭 아이콘이 통째로 사라졌다.BottomNav에는 이미 레지스트리 이름을 받는 통로(icon/activeIcon)가 있어서 그쪽으로 옮겼다..ts파일은 자동 교체 대상이 아니다. - 여는 태그를 따옴표 모르고 자르면 속성을 통째로 잃는다.
x-show="a?.added > 0"처럼 속성값 안에>가 있으면<svg[^>]*>가 거기서 끊겨 뒤따르는class와x-show가 사라진다. 레포 전체에 1곳이었지만 그 1곳은 조건부 표시가 사라져 아이콘 두 개가 겹쳐 보이게 된다. - 주석 안의
<svg가 뒤의 진짜 아이콘을 삼킨다. 비탐욕 매치가 주석 안에서 시작해 다음</svg>까지 먹으면 그 사이의 진짜 아이콘이 통째로 안 보인다. "주석 안이면 건너뛰기" 로는 못 막고 찾기 전에 주석을 같은 길이로 가려야 한다. 3곳을 놓쳤다가 잡았다. 감사 때 추출기가 똑같이 틀렸던 자리다(3.1 참조). - 앱별
main.css의> svg오버라이드도 미러링해야 한다. 2번에서 정본의 26개는 처리했는데 앱 쪽에 3개가 더 있었다(employee-app2 ·management-dashboard1). 안 하면 그 슬롯만 28px → 24px 로 줄어든다.
부수적으로 드러난 기존 버그 하나. my-info-form.html 의 연필·휴지통 아이콘 7개는 button.icon-btn 의 padding: 4px 12px + w-7(28px) + border-box 때문에 내용 상자가 4px 였고, 맨 <svg> 는 flex 아이템이라 거기 눌려 4×16 으로 렌더되고 있었다. 래퍼는 flex: none 이라 선언한 16px 를 지키므로 교체 후 제대로 나온다. 아이콘 쪽을 되돌릴 것이 아니라 .icon-btn 의 패딩이 손봐야 할 곳이다 — 이번 범위 밖으로 남긴다.
의도적으로 바꾼 것 하나. drive 의 모달 닫기 4곳은 크기 클래스가 없어 맨 <svg> 가 24px 버튼을 가득 채우고 있었다. 같은 자리 117곳이 전부 w-4 h-4(16px) 이므로 그쪽에 맞췄다.
두 상태 토글은 손으로 나눴다. 1번에서 유령 아이콘으로 판정한 ic-shuffle·ic-swords 는 자동 교체가 이름을 그대로 붙여 버려서, 원래의 x-show 조건을 살린 두 개의 $icon() 호출로 직접 갈랐다(햄버거/닫기, 체크/엑스).
2번 — 시범 교체에서 잡힌 것
apps/management-dashboard/src/views/dashboard.html 46곳을 바꿨다. 한 파일에 몰려 있고 Alpine 바인딩이 15곳으로 가장 빽빽해서 위험한 패턴이 다 들어 있다.
재는 방법. 아이콘 하나만 떼어 놓고 재면 의미가 없다. 크기를 잡는 것이 부모의 CSS 인 경우가 많기 때문이다. 그래서 소스에서 조상 체인을 그대로 뽑아 재구성한 뒤 앱의 실제 빌드 CSS 를 얹고, 글리프만 잘라 픽셀을 비교했다. <template> 은 체인에서 뺀다 — 안쪽이 DOM 에 안 잡힐뿐더러 x-for 은 런타임에 템플릿을 걷어내므로 실제 부모도 아니다.
잡힌 것 둘. 둘 다 조용히 깨지는 종류였다.
- 버튼·라벨 슬롯이 래퍼를 못 알아본다. 정본에
X > svg로 아이콘 크기를 잡는 규칙이 26개 있는데, 아이콘이<span class="ds-ico">에 싸여 들어오면 전부 빗나간다. 실측으로.ds-btn--line.ds-btn--md안에서 20px 이던 것이 14px 로 줄었다. 그 26개 규칙의 선택자에X > .ds-ico를 나란히 붙였다 — 값은 복사하지 않았으므로 크기는 계속 한 곳에서만 정의된다. 앞으로 아이콘 슬롯을 새로 만들 때 이 짝을 빠뜨리면 그 슬롯만 조용히 어긋난다. - 자기
width/height속성으로 크기를 잡던 아이콘. 레지스트리 항목에서는 그 속성을 걷어냈으므로 래퍼가 크기를 대신 줘야 한다. 안 주면 컨테이너를 가득 채운다 — 20px 짜리가 300px 로 렌더됐다. 교체 도구가 속성값을 Tailwind 크기 클래스로 옮기도록 고쳤다.
고친 뒤 결과. 크기 변화 0, 자리 이동 0, 사라진 아이콘 0. 46곳 중 28곳은 보이는 차이가 없고 17곳이 정본으로 바뀌었다(같은 아이콘의 다른 변형 → 정본). ic-menu 1곳은 이 화면 폭에서 렌더되지 않아 비교하지 못했다. 대조표: https://claude.ai/code/artifact/84e20e9c-2f11-4b2b-8688-e5da0671f8f0
교체 도구는 위치가 아니라 지문으로 찾는다. 감사 데이터의 줄번호는 스냅숏이라 이미 어긋나 있었다(파일이 그 사이에 바뀌었다). 그래서 현재 파일의 <svg> 를 기하 지문으로 정본 이름에 붙인다. 지문에서 획 굵기는 일부러 뺐다 — 굵기만 다른 변형을 모으는 것이 이 작업이므로 굵기를 넣으면 정작 합쳐야 할 것이 서로 다른 도형으로 잡힌다. 전체 정본 집합에서 한 기하가 두 이름에 걸리는 경우는 없음을 확인했다.
옮길 수 없는 것도 하나 나왔다. <path :d="c.iconPath"/> — 그림 자체를 런타임에 바인딩하는 자리다. 레지스트리 아이콘이 아니므로 그대로 두었다. 3번에서 같은 형태를 만나면 c.iconPath 가 경로 대신 아이콘 이름을 주도록 바꾸는 별도 작업이다.
0번 — 계획을 바꿔 전역 매직으로 갔다
원래 계획은 헬퍼가 없는 앱 6개의 main.ts에 icon() 메서드를 한 줄씩 다는 것이었다. 하지만 그 방식은 원인 1번(63%가 헬퍼를 쓸 수 있었는데도 안 썼다)을 못 고친다. 기존 icon()은 앱 최상위 x-data에 달린 컴포넌트 메서드라 중첩 스코프에서 끊긴다 — 모달· 위저드·x-for 안의 컴포넌트는 자기 x-data를 갖고 있어서 바깥 icon()에 닿지 않는다. 헬퍼가 이미 있는 drive에서조차 아이콘을 HTML로 직접 그린 곳이 남아 있던 이유가 이것이다.
그래서 컴포넌트 메서드가 아니라 Alpine 전역 매직으로 만들었다. 매직은 스코프와 무관하게 어느 표현식에서나 닿으므로 이 구멍이 사라진다.
<span class="w-4 h-4 text-label-normal" x-html="$icon('ic-folder')"></span>| 등록 위치 | 닿는 범위 |
|---|---|
packages/components/src/icons.ts 의 createIconPlugin() | 매직 정의 본체 |
createAuthComponentsPlugin() 이 내부에서 같이 등록 | 앱 9개 — 앱 파일은 한 줄도 안 고쳤다 |
apps/landing/src/main.ts · tools/system-admin/src/main.ts | SDK 를 안 쓰는 나머지 2개 |
packages/components/html/*.html 공용 템플릿은 빌드 타임에 앱 HTML 로 인라인되므로 앱의 Alpine 위에서 돌아간다 — 별도 등록이 필요 없다. apps/importer는 UI 가 없다. packages/static 의 27곳은 호출부가 아니라 .svg 파일 자체라 5번(추출) 소관이다.
기존 icon()(drive) · iconFor()(team-manager) 메서드는 그대로 두었다. 3번에서 호출부를 $icon으로 옮길 때 같이 지운다.
검증 — packages/components/src/icons.test.ts 가 스텁이 아니라 실제 Alpine 을 띄워 x-html="$icon(...)" 의 렌더 결과를 본다. 핵심은 중첩 x-data 안에서도 닿는지를 보는 테스트다. 그게 이 매직을 만든 이유이므로, 그 케이스가 깨지면 방식 자체가 무너진 것이다.
정본 선택 규칙 — 확정
기준 세트는 24 좌표계 stroke로 확정한다. 등록본 96개의 84%, 전체 출현의 60%가 이미 24 stroke이며, hereby DS 정본인 16 fill은 6개뿐으로 LNB 메뉴에만 쓰인다.
0. (전제) 정본은 자립해야 한다 — Tailwind 유틸리티 클래스로 색을 받으면 탈락
1. icons.ts 등록본이 있으면 그것이 정본 — 그림도 이름도 88개
2. 등록본이 둘이면 24 좌표계 중 굵기 낮은 쪽 1개
3. 등록본이 없으면 24 좌표계 최다 사용 47개
3-1. 24 후보가 없으면 최다 사용 그대로 21개
─────────────────
아이콘 155개이 규칙으로 사람 판단이 사라진다. 3.4의 "박빙 20개"도 대부분 등록본이 있어 자동으로 풀린다. 스크립트로 재도출해 저장된 선택과 대조했고 불일치 0이다.
0번 전제를 왜 붙였나 — 유틸리티 의존은 조용히 사라진다
ic-check-circle-fill·ic-x-circle-fill·ic-info-circle-fill 세 개는 원래 규칙대로면 24 좌표계 도형이 정본이 됐다. 그런데 그 도형들은 안쪽 글리프를 class="stroke-white" 로 칠한다. Tailwind 유틸리티는 그 문자열이 스캔된 앱에서만 생성된다 — 실제로 빌드 산출물을 보면 stroke-white 는 admin 에만 있고 hr-system·employee-app·team-manager 에는 없다. 레지스트리에 넣고 다른 앱에서 부르면 안쪽 흰 글리프가 사라져 까만 동그라미만 남는다.
CSS 변수는 반대다. --color-bg-elevated 같은 DS 토큰은 design-system.css 의 :root 선언이라 확인한 5개 앱 빌드 전부에 들어 있다. 그래서 금지 대상은 유틸리티 클래스뿐이고 CSS 변수는 그대로 써도 된다. 셋 다 자립하는 후보로 바꿨다.
스피너도 같은 이유로 손봤다 — class="opacity-25" 를 프레젠테이션 속성 opacity="0.25" 로 바꿨다. 렌더 결과는 같고 CSS 없이도 성립한다.
대가 — 화면 369곳이 바뀐다. 31개 이름에서 정본이 최다 사용본과 다르다. 등록본을 우선하기로 했으므로 사용량이 압도적인 것도 예외를 두지 않는다.
| 이름 | 버리는 것 | 채택 |
|---|---|---|
ic-close | 133회 (vb16·1.5) | 2회 (vb24·1.5) |
ic-info | 37회 | 1회 |
ic-trash | 36회 | 1회 |
ic-check | 25회 | 5회 |
ic-plus | 21회 | 6회 |
ic-chevron-right | 20회 | 3회 |
기능이 깨지는 것이 아니라 같은 아이콘의 굵기·크기가 미세하게 달라진다. 전체 1,016곳의 36%다. 바뀐 뒤에는 ic-close 186곳이 전부 같은 그림이 된다. 픽셀 회귀에서 diff가 크게 잡히므로 통과 기준을 "차이 없음"이 아니라 "예상된 369곳만 바뀜"으로 두는 것이 특히 중요하다.
이름도 등록본이 정본이다
155개 중 88개가 이미 등록된 이름을 그대로 쓴다. 감사 과정에서 임시로 붙인 이름은 버린다.
| 임시로 붙였던 이름 | 확정 (등록본) | 사용 |
|---|---|---|
ic-x-mark | ic-close | 186회 |
ic-info-circle | ic-info | 56회 |
ic-warning-triangle | ic-warning | 20회 |
ic-bars | ic-menu | 5회 |
ic-pencil | ic-edit | 5회 |
ic-chat | ic-message | 5회 |
ic-download-line | ic-import | 6회 |
ic-upload-line | ic-export | 3회 |
등록본에 없는 67개만 새로 붙인다. 예외는 둘이다.
- 접두사 통일. 등록본 5개(
chevron-down·chevron-left·chevron-up·chevron-right·chevron-left-double)가ic-없이 등록돼 있었다.ic-chevron-*로 붙여 통일한다. 접두사가 섞이면 호출할 때마다 어느 쪽인지 확인해야 한다. - 등록명이 그림과 맞지 않는 2건. 등록된
ic-clipboard는 실제로는 문서(줄 있는 네모)이고 진짜 클립보드는 미등록이었다.ic-document-list/ic-clipboard로 바로잡는다. 등록된ic-id-badge(외곽선)는ic-id-card로 옮긴다.
권위 있는 이름 출처가 하나 더 있었다 (1번 중 확인)
packages/static/icons/ 의 .svg 파일명도 이미 팀이 정한 이름이다. 등록본과 같은 자격인데 감사 때 이걸 안 보고 그림만 보고 이름을 붙여서 6개가 어긋나 있었다. 그중 하나는 아이콘을 지울 뻔했다 — ic-team.svg(외곽선)와 ic-team-fill.svg(채움)를 둘 다 ic-users-fill 로 묶어 놓아, 그대로 갔으면 외곽선·채움 짝 중 하나가 사라졌다.
| 감사가 붙인 이름 | 파일명 (확정) |
|---|---|
ic-archive | ic-workspace-fill |
ic-users-fill | ic-team · ic-team-fill (둘로 분리) |
ic-gear-fill | ic-setting-fill |
ic-sitemap-fill | ic-organization-fill |
ic-dollar-fill | ic-money-fill |
그래서 이름 권위 순서를 packages/static/icons/ 파일명 → icons.ts 등록키 → 감사 이름 으로 못 박았다. 파일명 오타 ic-chevrondoubble-left 만 예외로 ic-chevron-left-double 로 바로잡는다.
아이콘이 아니었던 것 4개
추출기가 <svg> 요소 단위로 잘랐기 때문에, 하나의 <svg> 안에 두 상태를 x-show 로 겹쳐 둔 토글이 도형 하나로 잡혔다. 겹쳐 놓고 보면 그럴싸한 아이콘처럼 보여서 감사 때도 이름이 붙었다.
ic-shuffle— 실제로는 랜딩 내비의 햄버거(☰) / 닫기(✕) 토글이다. 두 획이 겹쳐 셔플처럼 보였다.ic-swords— AI 어시스턴트 단계 표시의 체크(✓) / 엑스(✕) 토글이다. 겹쳐서 칼 두 자루로 보였다.
둘 다 레지스트리에서 뺐다. 호출부는 $icon('ic-menu') / $icon('ic-close') 처럼 원래의 x-show 조건을 유지한 채 두 번 부르면 된다.
나머지 둘은 apps/employee-app/ios/App/App/AppIcon.icon/Assets/ 의 iOS 앱아이콘 자산 (1024×1024 그라디언트 사각형, 아바타 자리표시자)이다. UI 아이콘이 아니라 제외했다.
이 과정에서 스펙에 미결로 남겨 뒀던 ic-unknown 2건이 모두 풀렸다. 하나는 위의 앱아이콘 자산이고, 다른 하나는 아바타 업로드 위의 카메라였다 — opacity-0 group-hover:opacity-100 이 걸려 있어 렌더할 때 아무것도 안 보였을 뿐이다. ic-camera 로 등록했다.
7. 검증
pnpm lint:check && pnpm build 외에 아래를 확인한다.
- 픽셀 회귀 — 교체 전후 화면을 렌더링해 비교. 교체하면 굵기·크기가 미세하게 달라지므로 통과 기준은 "차이 없음"이 아니라 **"예상된 차이만 있음"**이다. SVGO 검증에 쓴 스크립트를 재사용한다.
- 크기 클래스 368곳 — 버튼·라벨·탭·LNB·페이지 헤더에서 아이콘 크기와 정렬 확인.
- 애니메이션 70곳 — 스피너가 계속 돌고 트리 화살표가 접힘에 따라 회전하는지.
> svg규칙 52개 생존 — 기존 슬롯 구조가 유지됐는지.- BI 로고 다중 렌더 — 한 화면에 로고 둘 이상을 놓고 그라디언트가 섞이지 않는지. 지금 코드에 없던 실패 모드라 기존 회귀 테스트로는 잡히지 않는다.
- 실제 앱 확인 — 교체 후 브라우저로 띄워 육안 확인.
8. 실패 모드
| 상황 | 결과 |
|---|---|
| 차단 없이 정리만 함 | 6개월 뒤 원상복구. 63%가 "쓸 수 있었는데 안 씀"이었음 |
| 목록 없이 차단만 켬 | 개발자가 막힘. 우회하거나 예외 주석을 남발 |
| 등록 없이 차단 켬 | 그 앱에서 아이콘을 아예 못 씀 |
| id 재작성 누락 | 한 화면에 로고 둘 → 색 섞임 |
| (S3) 최초 방문 + 장애 | 아이콘 없이 부팅 |
| (S3) 버킷 쓰기 권한 유출 | 전 앱 XSS. sanitize + IAM 최소권한이 방어선 |
9. 부수 효과
- 두께 조절이 새로 열린다 (현재 불가능).
chevron-down과 BI 심볼 4개의 하드코딩된width/height가 사라져 크기 조절이 균일해진다.- (S3) 앱마다 gzip 17.8 KB가 번들에서 빠진다.
- (S3) static 버킷이 테넌트별이라 아이콘이 테넌트마다 복제된다. gzip 10.4 KB라 비용은 무의미하고, 각 테넌트가 자기 CloudFront에서 받으므로 지연은 오히려 낮다.