Skip to content

현재 사용법 (2026-09-10, 6fbaee7f): 공통 화면·디자인 시스템 · 기능 마스터. 아래는 작성 당시의 변경 설명이며 현재 전체 동작·재측정 결과를 뜻하지 않습니다.

아이콘 시스템 통합 ​

화면마다 굵기와 크기가 미묘하게 달랐던 같은 아이콘들이 하나의 그림으로 통일됩니다. 예를 들어 닫기(X) 아이콘은 화면에 따라 일곱 가지로 갈라져 있었고, 같은 24px 자리에 넣어도 14.0px에서 17.2px까지 벌어져 보였습니다. 이제 186곳이 전부 같은 그림입니다.

근본 원인은 하나입니다: 정본은 있었지만 강제력이 없었습니다 ​

아이콘 목록을 모아 둔 파일은 2026년 5월부터 있었습니다. 그런데 그 목록을 쓰도록 만드는 장치가 없었고, 심지어 목록을 부르는 함수가 화면 상당수에서는 호출조차 되지 않는 자리에 놓여 있었습니다. 그래서 개발자들은 매번 아이콘을 손으로 다시 그렸습니다.

배경 — 지금까지 아이콘이 그려지던 방식 ​

화면은 .html 파일로 작성하고 Alpine.js로 동작을 붙입니다. 아이콘은 그 .html 안에 SVG 좌표를 직접 적어 그렸습니다. 아래가 실제 코드입니다.

html
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
  <path d="M4 7h8M16 7h4M4 17h4M12 17h8" />
</svg>

d="M4 7h8…"가 선을 그리는 좌표입니다. 이렇게 화면 파일에 직접 적어 넣은 SVG를 이하 인라인 SVG라고 씁니다. 전수 조사 결과 224개 파일에 1,016곳이 있었습니다.

한편 packages/components/src/icons.ts 파일에는 아이콘 SVG 문자열을 이름으로 찾아 쓰도록 모아 둔 목록이 있었습니다. 이하 이것을 레지스트리라고 씁니다. getIcon("ic-close")처럼 이름을 주면 해당 SVG 문자열을 돌려줍니다. 2026년 5월에 만들어졌고 항목이 97개였습니다.

문제는 이 레지스트리가 거의 쓰이지 않았다는 것입니다. 1,016곳을 원인별로 나누면 이렇습니다.

원인규모
레지스트리에 그 아이콘이 있는데도 직접 그림645곳 (63%)
레지스트리에 없던 아이콘262곳 (26%)
그 앱에 레지스트리를 부르는 통로가 없음109곳 (11%)

63%가 압도적입니다. 이유가 둘입니다. 첫째로 막는 장치가 없었습니다. 인라인 SVG를 써도 린트도 커밋 훅도 리뷰도 통과했습니다. 둘째는 기계적인 이유입니다. 각 앱은 레지스트리를 부르는 함수를 화면 최상위의 x-data(Alpine이 상태를 붙이는 단위)에 메서드로 달아 두었는데, 모달이나 위저드 처럼 자기 x-data를 따로 가진 영역 안에서는 그 메서드에 닿지 않습니다. 그래서 63% 중 상당수는 쓰지 않은 것이 아니라 그 자리에서는 쓸 수 없었던 것입니다.

packages/static/icons/에 .svg 파일도 11개 있었습니다. 그러나 코드가 이 파일들을 읽은 적은 한 번도 없습니다. 화면에는 파일 경로를 주석으로 적어 두고 바로 아래에 SVG를 다시 인라인으로 그려 넣었습니다.

개념 다이어그램 ​

변경 전

변경 후

개념별로 본 변경 ​

1. 어디서나 부를 수 있게 만들었습니다 ​

앱마다 메서드를 다는 대신 Alpine의 전역 매직으로 등록했습니다. 매직은 $ 로 시작하는 이름으로 모든 Alpine 표현식에서 쓸 수 있는 값이며, x-data의 중첩과 무관하게 닿습니다.

html
<span class="ds-ico w-4 h-4 text-label-normal" x-html="$icon('ic-folder')"></span>

createAuthComponentsPlugin()이 내부에서 함께 등록하므로 그 플러그인을 쓰는 앱 9개는 파일을 한 줄도 고치지 않았습니다. SDK를 쓰지 않는 랜딩과 시스템 어드민 두 곳만 직접 등록했습니다.

검증은 실제 Alpine을 띄워서 합니다. 중첩된 x-data 안에서도 닿는지가 이 방식을 택한 이유이므로, 그 경우가 테스트의 핵심입니다.

2. 그림 298개를 이름 155개로 정했습니다 ​

전수 조사에서 나온 고유 도형 298개를 확정 규칙으로 이름 155개에 배정했습니다. 규칙은 레지스트리 등록본을 우선하고, 없으면 24 좌표계에서 가장 많이 쓰인 것을 택합니다. 스크립트로 다시 도출해 저장된 선택과 대조했고 불일치는 없었습니다.

정리 과정에서 감사 데이터의 오류가 드러났습니다. 이름의 권위 있는 출처가 하나 더 있었습니다. packages/static/icons/의 파일명도 팀이 정한 이름인데 이를 참조하지 않아 6개가 어긋나 있었고, 그중 ic-team(외곽선)과 ic-team-fill(채움)은 한 쌍인데 한 이름으로 묶여 있었습니다. 그대로 진행했으면 둘 중 하나가 사라졌습니다.

아이콘이 아닌 것도 넷 섞여 있었습니다. 하나의 <svg> 안에 두 상태를 x-show로 겹쳐 둔 토글이 도형 하나로 잡힌 경우가 둘(햄버거와 닫기, 체크와 엑스), iOS 앱아이콘 자산이 둘이었습니다.

정본에는 자립성 전제를 붙였습니다. 채움 상태의 원 세 개는 안쪽 흰 글리프를 class="stroke-white"로 칠하고 있었는데, Tailwind 유틸리티는 그 문자열을 스캔한 앱에서만 생성됩니다. 실제로 stroke-white는 admin 빌드에만 있고 hr-system과 team-manager에는 없습니다. 레지스트리에 넣고 다른 앱에서 부르면 안쪽 글리프가 사라져 까만 원만 남습니다. 그래서 유틸리티 클래스에 기대는 도형은 정본이 될 수 없게 했습니다. CSS 변수는 :root 선언이라 모든 앱에 있으므로 그대로 둡니다.

3. 아이콘을 담을 그릇을 정본으로 올렸습니다 ​

레지스트리가 돌려주는 SVG에는 width/height가 없습니다. 크기와 색은 감싸는 <span>이 잡고, 아이콘은 currentColor로 색을 따라옵니다. 이 <span>에 붙이는 클래스가 .ds-ico입니다.

이 클래스는 이미 있었지만 정의가 apps/hr-system/src/styles/main.css 한 곳에만 있었습니다. drive도 이 클래스를 쓰는데 정의가 없어서, 호출부 5곳이 전부 우연히 flex 부모 안에 있는 덕분에만 정상으로 보이고 있었습니다. 정의가 없으면 <span>이 display:inline이라 크기 클래스가 적용되지 않고, 주입된 <svg>는 컨테이너를 가득 채웁니다. 400px 폭에서 재면 16px가 400px로 렌더됩니다.

구조 부분만 design-system.css로 올렸습니다. 시트 화면의 획 굵기 통일은 hr-system이 자기 화면에 내린 결정이므로 올리지 않았습니다. 올리면 8개 앱의 아이콘 굵기가 한꺼번에 바뀝니다.

여기에 더해 슬롯 규칙을 함께 고쳐야 했습니다. 디자인 시스템에는 X > svg 형태로 아이콘 크기를 잡는 규칙이 26개 있는데, 아이콘이 <span>에 싸여 들어오면 이 규칙들이 전부 빗나갑니다. 실측으로 .ds-btn--line.ds-btn--md 안에서 20px이던 것이 14px로 줄었습니다. 값을 복사하지 않고 선택자에 X > .ds-ico를 나란히 붙여 크기 정의는 계속 한 곳에만 두었습니다. 앱별 CSS에도 같은 형태가 셋 더 있었습니다.

4. 895곳을 옮기면서 네 가지가 드러났습니다 ​

한 파일(46곳)로 먼저 시험한 뒤 전체로 넓혔습니다. 아이콘 하나만 떼어 재면 의미가 없으므로, 소스에서 조상 요소를 그대로 재구성하고 앱의 실제 빌드 CSS를 얹은 뒤 글리프만 잘라 픽셀을 비교했습니다. 853곳 중 822곳이 크기와 자리 그대로입니다.

  • 자기 width/height 속성으로 크기를 잡던 아이콘. 레지스트리 항목에서는 그 속성을 걷어냈으므로 래퍼가 크기를 대신 줘야 합니다. 주지 않으면 20px짜리가 300px로 렌더됩니다.
  • TS 템플릿 문자열 안의 x-html은 동작하지 않습니다. Alpine은 주입된 내용의 지시문을 다시 훑지 않습니다. 직원 앱 하단 탭 아이콘 8개가 여기 걸렸고, 그대로 뒀으면 탭 아이콘이 통째로 사라졌습니다. 설정에 이름을 넘기는 통로가 이미 있어 그쪽으로 옮겼습니다.
  • 여는 태그를 따옴표를 모르고 자르면 속성을 잃습니다. 속성값 안에 >가 있으면 거기서 끊겨 class와 x-show가 사라집니다.
  • 주석 안의 <svg>가 뒤의 진짜 아이콘을 삼킵니다. 주석 안이면 건너뛰는 방식으로는 막지 못하고, 찾기 전에 주석을 같은 길이로 가려야 합니다.

옮기는 과정에서 기존 버그도 하나 드러났습니다. 내 정보 화면의 연필과 휴지통 7개는 버튼의 padding: 4px 12px와 w-7(28px), border-box 때문에 내용 상자가 4px였고, 맨 <svg>는 flex 아이템이라 거기 눌려 4×16으로 렌더되고 있었습니다. 래퍼는 flex: none이라 선언한 크기를 지키므로 교체 후 제대로 나옵니다.

5. 되돌아오지 않게 막았습니다 ​

scripts/check-icons.mjs를 기존 모달·다이얼로그 검사와 같은 형태로 만들어 pre-commit에 걸었습니다. 두 가지를 보는데 범위가 서로 다릅니다.

인라인 <svg>를 새로 그리는 것은 추가된 줄만 봅니다. 없는 이름을 부르는 것은 전체 파일을 봅니다. getIcon()은 없는 이름에 빈 문자열을 돌려주므로 오타 하나면 아이콘이 에러도 로그도 없이 사라지고, 레지스트리에서 이름 하나를 고치면 손대지 않은 파일의 호출부가 전부 그 상태가 되기 때문입니다. 실제로 만들자마자 개명 후 남아 있던 호출 두 곳을 잡았습니다.

정당한 예외는 브랜드 자산과 런타임에 그림을 만드는 것(차트, 서명 애니메이션)뿐이며, 각 자리에 사유를 적어 두었습니다.

6. 파일로 꺼내고 SVGO 설정을 다시 쟀습니다 ​

pnpm icons:build가 레지스트리를 읽어 packages/static/icons/*.svg 161개를 만듭니다. .svg는 손으로 고치는 파일이 아니라 파생 산출물이며, pnpm icons:check가 어긋나면 막습니다. 이 구분이 없으면 같은 아이콘이 두 곳에 있으면서 서로 어긋납니다. 지난 석 달이 정확히 그 상태였습니다.

설계 문서에 적혀 있던 SVGO 설정을 그대로 쓰려다 다시 쟀는데 결과가 달랐습니다. 문서에는 픽셀이 모두 일치한다고 적혀 있었으나 새 정본 161개로 재니 24px에서 32개가 눈에 띄게 달라졌습니다. 원인은 convertPathData의 makeArcs입니다. 베지에 곡선을 호로 바꾸는 근사이므로 형태가 미세하게 달라집니다.

설정크기gzip24px 차이
기본71.5 KB14.5 KB32 / 161
호 변환만 끔 — 채택74.6 KB16.1 KB14 / 161
정밀도 상향84.4 KB20.3 KB11 / 161
경로를 손대지 않음97.4 KB24.3 KB1 / 161

최적화는 표현을 줄이는 것이지 그림을 다시 그리는 것이 아니므로 호 변환만 껐습니다. 경로를 아예 건드리지 않으면 gzip이 원본과 같아져 압축할 이유가 사라집니다. 남은 14개는 대각선 가장자리의 서브픽셀 차이이며 최악이 2,304픽셀 중 7픽셀입니다.

7. 커스텀 축을 확인했습니다 ​

색·크기·방향·애니메이션은 래퍼가 처리합니다. 브라우저에서 확인한 결과 모두 동작합니다. 획 굵기만 인자로 받습니다. stroke-width는 상속되는 속성이지만 아이콘의 <svg>가 자기 값을 프레젠테이션 속성으로 들고 있어 부모가 물려줄 수 없기 때문입니다. 래퍼에 stroke-width: 3을 줘도 1.5px 그대로였습니다.

html
<span class="ds-ico w-4 h-4 text-orange-500" x-html="$icon('ic-folder')"></span>
<span class="ds-ico w-6 h-6 rotate-180"      x-html="$icon('ic-chevron-right')"></span>
<span class="ds-ico w-10 h-10" x-html="$icon('ic-folder', { stroke: 1.2 })"></span>

확인 중에 색 상속이 되지 않는 아이콘을 하나 찾았습니다. ic-exclamation은 fill과 stroke 속성이 아예 없어 SVG 기본값인 검정으로 렌더됐고, text-red-500 안에 넣어도 검정이었습니다. currentColor를 붙여 고쳤습니다.

남긴 것 ​

  • 아이콘 목록 페이지를 만들지 않았습니다. 차단은 있으나 무엇을 쓸지 찾아볼 곳이 없으므로 실패 메시지가 사용법을 안내하는 것으로 대신하고 있습니다.
  • S3 업로드와 $icon의 fetch 전환은 하지 않았습니다. 그 시점에 check-icons.mjs가 이름 목록을 레지스트리 대신 .svg 디렉터리에서 읽도록 바꿔야 합니다.
  • CI에는 이 검사가 없습니다. 기존 모달·다이얼로그 검사와 린트도 마찬가지로 워크플로에서 돌지 않으며, 이번 범위 밖으로 두었습니다.
  • .icon-btn의 패딩은 고치지 않았습니다. 4번에서 드러난 눌림의 근본 원인이지만 레거시 클래스라 영향 범위가 넓습니다.