Skip to content

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

로딩 스피너 — 정본화 · 확장 축 개방 · 사본 차단 ​

브랜치 design/update-loading-components · 2026-08-13

한 줄 요약 ​

화면에 보이는 변화는 한 가지뿐입니다. 급여 시트의 [설정 완료] 를 누른 뒤 잠깐 뜨는 스피너가, OS 접근성 설정 '동작 줄이기' 를 켠 사용자에게 다른 스피너들과 똑같이 느리게(1초 → 2.4초) 돕니다. 그 외에는 어느 화면도 달라지지 않습니다. 나머지 변경은 앞으로 스피너를 늘리거나 고칠 때 같은 사본이 다시 생기지 않게 하는 작업입니다.

여는 한 줄 ​

스피너는 이미 전 앱에서 돌고 있었지만, 규칙의 정본이 design-system.css 의 주석 한 곳뿐이었습니다. 그래서 규칙을 강제할 자리도, 확장할 자리도 없었습니다. 이 PR 의 네 갈래(문서 · 사본 정리 · 변수화 · 게이트)는 전부 그 하나에서 나옵니다.

배경 — 지금 어떻게 동작하나 ​

hereby 의 로딩 스피너는 두 종류입니다.

  • 영역 링 — .ds-spinner 클래스를 단 빈 <span> 입니다. <div> 의 테두리 한 변만 색을 달리해 회전시키는 방식이라 그림 파일이 없습니다. 화면이나 패널이 아직 아무것도 그리지 못하는 동안 씁니다. 이하 이것을 링이라고 씁니다.
  • 인라인 아이콘 — 아이콘 레지스트리의 ic-spinner 라는 SVG 를 Tailwind 의 animate-spin 으로 돌립니다. [저장] 버튼처럼 그 컨트롤 하나만 바쁠 때 씁니다.

링을 콘텐츠 위에 겹쳐 띄울 때는 .ds-spinner-badge 로 감쌉니다. 흰 원형 배경에 그림자를 준 받침이며, 이하 받침이라고 씁니다.

링이 SVG 아이콘이 아니라 <div> 테두리인 데에는 이유가 있습니다. SVG 의 transform 애니메이션은 메인 스레드가 막히면 합성 레이어로 올라가지 못해 프레임이 튑니다. 영역 로딩은 정의상 메인 스레드가 바쁜 순간이라 정확히 그때 끊깁니다. 시트 로딩에서 회전이 끊긴다는 리포트가 실제로 있었고, 그 대응으로 <div> 테두리 회전에 will-change: transform 을 붙인 것이 현재 구현입니다.

링을 쓰는 자리는 네 곳입니다. 앱 부팅 화면(packages/components/html/loading.html), admin 이 다른 앱을 iframe 으로 띄울 때의 임베드 오버레이(apps/admin/src/views/tabs/embed.html), 급여 시트의 첫 페이지 로딩(apps/hr-system/.../sheets/grid.html), 그리고 시트 [설정 완료] 처리 중 화면(apps/hr-system/.../modals/setup-complete.html) 입니다. 네 자리가 같은 링을 쓰는 것은 의도입니다. admin 에서 시트를 열면 부팅 · iframe · 시트 로딩이 이어지는데, 모양이 같아야 스피너 하나가 계속 도는 것처럼 보입니다.

문제는 네 번째 자리였습니다. [설정 완료] 화면의 스피너만 .ds-spinner 를 쓰지 않고, 같은 링을 인라인 style 로 다시 그려 두고 있었습니다. 이하 이런 것을 사본이라고 씁니다.

html
<span class="animate-spin rounded-full"
  style="width:2rem; height:2rem; border:3px solid var(--color-neutral-200);
         border-top-color: var(--color-orange-500);"></span>

겉보기는 같습니다. 그러나 사본은 두 가지를 받지 못합니다. 첫째로 정본을 고쳐도 따라오지 않습니다. 둘째로 design-system.css 의 prefers-reduced-motion 규칙이 .ds-spinner 에만 걸려 있어, '동작 줄이기' 를 켠 사용자에게 다른 스피너는 2.4초로 느려지는데 이 자리만 1초로 돌았습니다.

사본이 생긴 경로도 확인했습니다. 모달 패턴 문서(docs/ko/design-system/patterns/modal.md)의 화이트 딤 예제 코드에 같은 사본이 그대로 실려 있었습니다. 규칙 정본이 CSS 주석에만 있었기 때문에, 문서는 그 주석을 참조하지 못한 채 구현 코드를 그대로 옮겨 적었고, 그 결과 문서가 사본을 배포하고 있었습니다.

개념 다이어그램 ​

규칙 정본과 사본 발생 경로 ​

변경 전

변경 후

변수 해석 경로 ​

변경 전

변경 후

개념 흐름별 변경 ​

규칙의 정본을 주석에서 문서로 옮깁니다 ​

docs/ko/design-system/components/feedback/spinner.md 를 신설합니다. 두 종류를 무엇으로 구분하는지(자리로 정하며, "이 스피너를 지우면 그 자리에 무엇이 보이는가" 가 판단 기준입니다), 규격, 확장 절차, 그리고 스피너가 아닌 로딩 표시가 무엇인지를 담습니다. 마지막 항목은 진행률을 아는 경우(N/M)에 스피너 대신 카운트 배너를 써야 한다는 규칙이며, 스켈레톤과 진행률 바는 디자인 시스템에 존재하지 않는다는 사실도 함께 명시합니다. 존재하지 않는 컴포넌트를 쓰라는 지시가 나오는 것을 막기 위해서입니다.

정본이 생겼으므로 참조하는 쪽도 배선합니다. 에이전트용 라우터(.claude/rules/frontend/design-system-docs.md)에 한 행, VitePress 사이드바에 한 항목, CLAUDE.md 에 가드레일 한 줄을 넣습니다. 변경 이력(changelog.md)도 함께 갱신합니다. 디자인 시스템 문서를 고치면 변경 이력을 같은 작업에서 갱신한다는 운영 규칙이 있습니다.

문서와 코드에 남아 있던 사본을 정리합니다 ​

setup-complete.html 의 인라인 사본을 <span class="ds-spinner"></span> 으로 되돌립니다. 이 자리는 흰 딤 위이므로 받침을 씌우지 않습니다. 흰 바탕에 흰 원이 겹쳐 테두리만 도드라지기 때문입니다.

modal.md 의 예제 코드도 같이 고칩니다. 코드만 고치고 문서를 두면 문서가 계속 사본을 배포합니다. 예제 아래에 공용 클래스를 쓰라는 항목과 흰 딤 위에서 받침을 쓰지 않는다는 항목을 추가합니다.

확장 축을 변수로 엽니다 ​

모양을 정하는 다섯 값(지름 · 두께 · 트랙 색 · 인디케이터 색 · 받침 지름)을 CSS 변수로 분리합니다. 기본값은 종전과 같으므로 렌더 결과는 변하지 않습니다.

목적은 새 종류의 추가 비용을 낮추는 것입니다. 다른 크기의 스피너가 필요할 때 링을 다시 그리는 대신 .ds-spinner--<이름> 에서 변수만 덮게 합니다. 회전 메커니즘(테두리 · keyframe · will-change)을 공유해야 GPU 합성 이점과 prefers-reduced-motion 대응이 새 종류에도 따라오기 때문입니다.

크기 변형(--sm / --lg)을 미리 정의하는 방안은 채택하지 않았습니다. 현재 네 자리가 모두 기본 크기를 쓰므로 사용처 없는 CSS 가 됩니다.

변수가 조상에서 내려오지 않게 막습니다 ​

앞 항목이 새 경로를 하나 만듭니다. CSS 변수는 기본이 상속이므로, 어떤 조상이 --ds-spinner-size 를 설정하면 그 아래 모든 링이 따라 바뀝니다. 패널 안 스피너 하나를 줄이려던 설정이 그 안에 든 부팅 · 임베드 스피너까지 함께 줄이는 결과가 됩니다.

다섯 축을 @property { syntax: "*"; inherits: false } 로 등록해 그 경로를 막습니다. 변형 클래스와 요소 자신의 선언만 적용되며, 이것이 의도한 확장 방식입니다.

initial-value 를 두지 않은 이유가 있습니다. @property 의 initial-value 는 var() 를 받지 못하므로 색을 지정하려면 hex 를 직접 적어야 하고, 이는 하드코딩 색을 금지하는 토큰 규칙에 어긋납니다. syntax: "*" 에 initial-value 를 생략하면 초기값이 guaranteed-invalid 가 되어 var(--ds-spinner-track, var(--color-neutral-200)) 의 폴백이 그대로 동작하고, 토큰 참조를 유지할 수 있습니다.

@property 를 지원하지 않는 브라우저는 이 블록을 무시합니다. 변수가 종전처럼 상속될 뿐 렌더 결과는 동일합니다.

사본 재유입을 커밋에서 막습니다 ​

scripts/check-spinners.mjs 를 신설하고 .husky/pre-commit 에 차단 모드로 배선합니다. 회전(animate-spin)과 원형 테두리 기하를 한 줄에서 조합한 링을 잡습니다.

철자를 두 가지 다 잡아야 했습니다. 이번에 정리한 인라인 style 형과, 감사 과정에서 새로 발견한 Tailwind 유틸 조합 형(border-2 border-orange-500 border-t-transparent rounded-full animate-spin)입니다. 후자는 앞선 조사에서 누락했던 형태입니다.

아이콘 스피너는 대상이 아닙니다. 같은 줄에 ds-ico 나 $icon( 가 있으면 건너뜁니다. 버튼 안 인라인 스피너는 정상 사용법입니다. 로딩 표시가 아닌 회전 원(진행률 링 · 차트 조각)은 ds-spinner-exempt: <사유> 로 통과시킵니다.

기존 게이트들과 같이 추가된 줄만 봅니다. 유틸 조합 형 레거시가 3곳 있어 전수를 막으면 그 파일을 수정할 수 없습니다. 목적은 과거 청산이 아니라 새 사본 차단입니다.

판정기는 7개 케이스로 확인했습니다. 차단 2건(인라인 사본 · 유틸 조합 링), 통과 5건(.ds-spinner · 버튼 안 아이콘 · 회전하는 새로고침 아이콘 · 둥근 아바타 · 테두리 없는 회전)입니다.

남은 마이그레이션 대상을 기록합니다 ​

두 종류가 남습니다. ic-spinner 를 영역 로딩에 쓰는 자리가 11곳(admin 스케줄러 4 · team-manager 3 · inventory · general-affairs · module-manager 대시보드 · system-admin 부팅 화면), 손으로 그린 링이 3곳(employee-app 2 · team-manager 1)입니다.

둘 다 마이그레이션 대상이며 위반이 아닙니다. 일괄 스윕하지 않고 해당 파일을 수정할 때 함께 옮깁니다. 전 앱이 design-system.css 를 로드하므로 치환 자체는 한 줄입니다.

검증 ​

계산된 스타일을 실제 빌드 산출물(apps/hr-system/dist/assets/index-*.css)로 대조했습니다.

항목결과
기본 링32px · 3px · rgb(246, 96, 20) · 1s linear (변경 전과 동일)
받침64px · 흰 배경 · 그림자 유지
조상에 변수 설정32px · 오렌지 유지 (차단됨)
변형 클래스20px · 2px (적용됨)
'동작 줄이기'사본 1s → 공용 2.4s

빌드 산출물에 @property 선언 5개가 보존되는 것도 확인했습니다. 일부 CSS 최적화 도구가 알 수 없는 at-rule 을 제거하기 때문입니다.

pnpm lint:check 통과(에러 0), pnpm build 22/22, docs 빌드 통과, 게이트 4종(dialogs · modals · icons · spinners) 통과, preflight --full 17 passed / 0 flagged 입니다.