일관성을 위한 디자인 시스템 구축기
velog
색상 토큰, 타이포그래피, 인터랙션을 하나씩 정리하기 운영툴의 기능과 메뉴가 늘어나면서 UI에도 조금씩 다른 패턴이 쌓이기 시작했습니다. 같은 역할의 색상이 서로 다른 값으로 사용되거나, 비슷한 버튼인데도 hover와 active 상태의 표현이 다르고, 아이콘의 크기와 간격도 조금씩 달랐습니다. 각 화면만 놓고 보면 큰 문제는 아니지만, 기능이 계속 추가되는 상황에서는 같은 역할을 하는 UI에 동일한 기준을 적용할 필요가 있다고 생각했습니다. 이번에는 Sidebar UI를 개선하면서 기존 구조를 정리하고, 그 과정에서 반복해서 사용되는 색상, 타이포그래피, 아이콘, hover/focus와 같은 인터랙션 기준 을 함께 정리해보았습니다. 처음부터 별도의 디자인 시스템이나 공통 컴포넌트 라이브러리를 만드는 것이 목표는 아니었습니다. 기존 구조를 최대한 유지하면서 실제로 공통 기준으로 가져갈 수 있는 부분과 각 컴포넌트에 남겨두는 것이 나은 부분을 하나씩 구분하는 방식으로 진행했습니다. Sidebar를 시작점으로 Sidebar는 대부분의 화면에서 항상 노출되는 영역입니다. 메뉴가 늘어나면서 메뉴 계층, 권한에 따른 노출, 접기/펼치기, active 상태, tooltip, 아이콘, 브랜드 영역 등 여러 UI 기준이 한곳에 모여 있었습니다. 특정 페이지 하나를 수정하는 것보다 여러 패턴을 동시에 확인할 수 있었기 때문에 Sidebar를 공통 UI 기준을 정리하는 시작점으로 잡았습니다. 기존 Sidebar에는 메뉴 렌더링뿐 아니라 권한과 설정값에 따른 노출 조건도 함께 들어 있었습니다. 따라서 단순히 화면을 새롭게 구성하는 것보다는 기존 동작을 유지하면서 구조와 표현을 정리하는 것 을 우선했습니다. 공통화의 범위 정하기 처음에는 Sidebar 자체를 좀 더 범용적인 공통 컴포넌트 형태로 분리하는 방향도 검토했습니다. 하지만 현재 화면에서만 필요한 책임까지 별도의 shared component로 분리하면 파일은 늘어나고, 실제 변경 내용을 파악하기는 오히려 어려워질 수 있었습니다. 최종적으로는 기존 SideBar , SideBarItem 을 중심으로 유지하면서 역할이 명확하게 분리되는 부분만 나누었습니다. SideBar ├─ SidebarBrand ├─ NavSection │ └─ SideBarItem └─ Account / Settings 공통화의 기준도 단순히 “다른 곳에서 재사용할 수 있는가?” 에 두지 않았습니다. 분리했을 때 기존보다 책임이 명확해지고, 코드를 이해하기 쉬워지는지를 우선해서 판단했습니다. 재사용 가능성이 있다는 이유만으로 abstraction을 늘리는 것보다 현재 코드에서 필요한 역할을 명확하게 만드는 쪽이 더 중요했습니다. 색상 값이 아니라 역할을 정의하기 Sidebar 스타일을 정리하면서 비슷한 계열의 색상이 여러 위치에서 각각 다른 값으로 사용되고 있는 부분도 함께 확인했습니다. 단순히 기존 색상을 CSS 변수로 옮기는 것만으로는 의미가 크지 않다고 생각했습니다. 중요한 것은 색상 값 자체보다 UI에서 어떤 역할을 하는 색상인가 였습니다. 예를 들어 다음처럼 역할을 기준으로 token을 구성했습니다. --color-brand-accent: ...; --color-brand-accent-strong: ...; --color-brand-accent-soft: ...; --color-brand-accent-border: ...; --color-text: ...; --color-text-secondary: ...; --color-text-muted: ...; --color-border: ...; --color-hover: ...; active 상태의 색상을 특정 orange 값으로 관리하는 대신 brand-accent 라는 의미로 정의했습니다. 이렇게 해두면 이후 브랜드 컬러가 변경되더라도 같은 역할을 하는 UI를 한 곳에서 조정할 수 있습니다. 반대로 모든 값을 token으로 만들지는 않았습니다. Sidebar width나 item height처럼 한 컴포넌트 안에서만 의미가 있는 값까지 전부 token으로 올리면 어떤 값이 실제 공통 기준인지 오히려 불분명해질 수 있기 때문입니다. 이번 작업에서는 여러 UI에서 같은 의미로 반복되는 값은 token으로, 특정 컴포넌트의 구현 세부사항은 해당 컴포넌트에 남기는 것 을 기준으로 두었습니다. Tailwind와 CSS의 경계 프로젝트에서는 Tailwind를 사용하고 있기 때문에 spacing이나 flex layout처럼 일반적인 스타일은 가능한 한 template에서 처리했습니다. 하지만 Sidebar를 정리하다 보니 utility class만으로 처리하는 것이 오히려 복잡해지는 부분도 있었습니다. Scrollbar pseudo-element, Vue <Transition> 의 enter/leave class, tooltip arrow, Teleport된 tooltip, active/hover/collapsed 상태가 겹치는 스타일 등이 대표적인 경우였습니다. 이런 스타일은 별도의 sidebar.css 에 남겼습니다. 즉 CSS 파일을 없애는 것이 목표가 아니라, Tailwind가 잘 처리하는 부분은 Tailwind로, CSS 기능 자체가 필요한 부분은 CSS로 역할을 구분했습니다. 한 컴포넌트에서만 사용하는 단순 spacing이나 layout 때문에 별도의 CSS를 늘리지는 않고, CSS로 관리할 이유가 명확한 부분만 남기는 방식입니다. 상태는 닫혔는데, 왜 메뉴는 계속 보일까? Section 메뉴의 접기/펼치기 기능을 확인하면서 예상하지 못했던 문제가 하나 있었습니다. 코드상으로는 isOpen 값이 정상적으로 false 가 되었고 chevron도 닫힌 상태로 바뀌었지만, 하위 메뉴는 화면에서 계속 보였습니다. 처음에는 Vue Transition의 타이밍 문제를 의심했습니다. 하지만 실제 element와 computed style을 확인해보니 원인은 CSS 우선순위였습니다. 프로젝트에서는 Tailwind를 global important 모드로 사용하고 있었습니다. @import 'tailwindcss' important; 따라서 grid utility는 최종적으로 다음과 같이 적용됩니다. .grid { display: grid !important; } 반면 Vue의 v-show 는 element에 inline style을 추가합니다. style="display: none;" 결과적으로 다음 두 스타일이 충돌하게 됩니다. v-show display: none vs Tailwind display: grid !important Tailwind의 !important 가 우선되면서 Vue의 상태 값과 실제 화면이 달라졌던 것입니다. 여기에 다시 display: none !important 를 추가해서 해결할 수도 있었지만, CSS 우선순위를 한 단계 더 복잡하게 만들고 싶지는 않았습니다. 그래서 해당 영역은 v-show 대신 v-if 로 변경해 DOM 자체를 mount/unmount하도록 처리했습니다. <Transition name="nav-section-items"> <div v-if="showItems"> ... </div> </Transition> 수정 자체는 작았지만, 상태 값만 확인했다면 원인을 찾기 어려운 문제였습니다. 이후에는 UI 상태가 예상과 다를 때 framework 상태뿐 아니라 실제 DOM과 computed style까지 함께 확인 하는 쪽으로 검증 범위를 넓혔습니다. Hover와 Focus 상태 다루기 Sidebar가 접힌 상태에서는 메뉴명을 직접 표시할 수 없기 때문에 hover나 focus 시 tooltip을 보여주도록 구성했습니다. 그런데 메뉴를 클릭해 route 이동이 완료된 후에도 tooltip이 계속 남는 문제가 있었습니다. 처음에는 hover 상태가 해제되지 않는 문제라고 생각했습니다. 하지만 마우스를 완전히 다른 영역으로 이동해도 tooltip이 사라지지 않았습니다. document.activeElement 를 확인해보니 원인은 hover가 아니라 focus 였습니다. 메뉴 버튼을 마우스로 클릭하면 브라우저가 해당 button에 focus를 남기고 있었고, tooltip 표시 조건은 다음과 같은 형태였습니다. collapsed && (isHovered || isFocused) 따라서 mouseleave가 발생해 isHovered 가 false 가 되어도 isFocused 가 계속 true 라 tooltip이 남아 있었습니다. 단순히 click마다 blur() 를 호출하면 바로 해결할 수 있지만, 그렇게 하면 키보드 사용자가 Enter 나 Space 로 메뉴를 실행했을 때도 focus가 사라집니다. 그래서 pointer click인 경우에만 focus를 해제하도록 범위를 좁혔습니다. if (event.detail > 0) { button.value?.blur(); } 마우스로 클릭했을 때는 navigation 후 tooltip이 닫히고, Tab으로 focus한 경우에는 tooltip이 보이며, Enter나 Space로 이동한 경우에는 keyboard focus가 유지되도록 했습니다. 작은 tooltip 동작이었지만 마우스 UX만 기준으로 수정하면 키보드 인터랙션에서 또 다른 regression을 만들 수 있는 부분이었습니다. 같은 17px인데 왜 다르게 보일까? Collapsed와 Expanded 상태의 아이콘을 비교하면서 Expanded 쪽 아이콘이 조금 더 작아 보이는 부분도 있었습니다. 처음에는 상태별로 다른 size가 적용된 것이라고 생각했습니다. 하지만 실제 렌더링 값을 측정해보니 두 상태 모두 같은 크기였습니다. icon container: 18 × 18px svg: 17 × 17px 차이는 주변 layout에서 발생했습니다. Collapsed 상태에서는 정사각형 영역의 중앙에 아이콘만 배치되지만, Expanded에서는 텍스트와 함께 배치되면서 padding과 주변 요소의 영향을 받습니다. 즉 computed size는 같지만 optical size는 다르게 느껴질 수 있는 상태 였습니다. 설정 버튼과 Sidebar toggle을 정리할 때도 같은 기준을 적용했습니다. 실제 클릭 영역은 충분한 크기로 유지하고, 화면에서 보이는 hover/focus surface는 별도로 다뤘습니다. 실제 hit area ┌────────────────┐ │ │ │ ┌──────┐ │ │ │ icon │ │ ← hover / focus surface │ └──────┘ │ │ │ └────────────────┘ 기본 상태에서는 배경을 표시하지 않고 hover나 focus가 발생했을 때만 rounded surface를 보여주는 식입니다. 결국 디자인 기준을 맞춘다는 것은 width , height 같은 숫자를 동일하게 만드는 것만으로는 부족했습니다. 같은 역할의 UI가 비슷한 크기와 방식으로 인식되고, 동일한 방식으로 반응하는지 까지 확인할 필요가 있었습니다. Typography 기준 정리 Sidebar 브랜드 영역을 정리하면서 전역 font token도 함께 확인했습니다. 처음에는 다음과 같은 설정이 있었습니다. --font-body: 'Pretendard', 'Pretendard Variable', ...; --font-display: 'Chakra Petch', var(--font-body); 그런데 실제 프로젝트를 확인해보니 Chakra Petch 는 dependency에도 없었고 별도의 CSS import나 @font-face 도 존재하지 않았습니다. 즉 설정에는 존재하지만 실제로는 로드되지 않는 font였습니다. Pretendard도 현재 사용하는 CSS를 직접 확인해보니 @font-face 에 정의된 family는 다음 하나였습니다. font-family: 'Pretendard Variable'; 이에 맞춰 token도 실제 runtime에서 유효한 값을 기준으로 정리했습니다. --font-body: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Malgun Gothic', 'Noto Sans KR', sans-serif; --font-display: var(--font-body); 설정 파일에 font 이름이 있다고 해서 실제 사용할 수 있는 font인 것은 아니었습니다. 디자인 token도 결국 runtime에서 실제로 유효한 값과 연결되어 있어야 했습니다. 웹폰트 로딩 최적화 Pretendard를 적용한 뒤 production build의 asset도 확인해보았습니다. 처음 적용한 Variable font는 WOFF2 한 파일이 약 1.96MiB 였습니다. font-display: swap 이 적용되어 있어 초기 paint 자체를 막는 구조는 아니었지만, UI 개선을 위해 정적 resource를 2MB 가까이 추가하는 것은 확인해볼 필요가 있었습니다. Pretendard에는 dynamic subset 방식도 제공되고 있었습니다. 다만 subset이라는 이름만 보고 바로 변경하지는 않았습니다. Dynamic subset의 WOFF2 파일 전체를 합치면 오히려 full variable보다 크기 때문입니다. 차이는 unicode-range 에 있었습니다. Dynamic subset에서는 현재 페이지에서 필요한 문자에 해당하는 font chunk만 브라우저가 요청합니다. 그래서 동일한 production build와 navigation sequence를 기준으로 두 방식을 직접 비교했습니다. 방식 첫 진입 Player 화면 이동 후 누적 Full Variable 약 1.96 MiB 약 1.96 MiB Dynamic Subset 약 181 KiB 약 206 KiB 첫 진입 기준으로는 약 90% 정도의 font transfer를 줄일 수 있었습니다. Player 화면으로 이동할 때 필요한 chunk 하나가 추가로 요청되었지만, 이후 여러 한글 화면을 이동해도 추가 font request는 발생하지 않았습니다. 사용하고 있는 font weight와 한글 glyph도 함께 확인한 뒤 최종적으로 import를 변경했습니다. @import 'pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css'; 여기서 중요하게 본 것은 build 결과에 생성된 전체 asset의 합이 아니라 실제 브라우저가 얼마를 요청하는가 였습니다. 파일이 여러 개로 나뉘었다는 것만으로는 최적화라고 보기 어렵기 때문에 실제 Network 데이터를 기준으로 적용 여부를 결정했습니다. 공통 Layout 변경의 영향 범위 Sidebar를 변경하면서 예상하지 못했던 다른 화면의 regression도 발견했습니다. 상위 layout의 flex/scroll 구조가 달라지면서 Player 상세 화면에서 검색 영역이 지나치게 줄어들거나 내부 scrollbar가 중첩되는 문제가 발생했습니다. Sidebar 자체에서는 정상적으로 보였지만, 상위 layout sizing이 변경되면서 하위 페이지의 flex와 overflow까지 영향을 받은 것입니다. 결국 상세 화면에서 어느 element가 scroll owner가 되어야 하는지를 다시 정리했습니다. 이후에는 Sidebar만 확인하지 않고 주요 화면으로 이동하면서 검색 영역 높이, 상세 영역의 scroll, content sizing, Sidebar collapse/expand 이후 layout까지 함께 확인했습니다. 공통 Layout을 수정하는 작업에서는 수정한 component 하나만 정상적으로 보이는 것으로 검증을 끝내기 어렵다는 점을 확인한 부분이었습니다. 구조를 옮길 때 놓치기 쉬운 정책 Navigation 구조를 정리하면서 기존 SideBar.vue 안에 있던 메뉴 구성 로직을 별도의 navigation tree로 분리했습니다. 그런데 작업 후 특정 설정값에 따라 숨겨져야 하는 레거시 메뉴가 계속 표시되는 문제가 있었습니다. 확인해보니 기존 SideBar.vue 에는 단순한 route rendering뿐 아니라 특정 설정값을 확인해 메뉴를 숨기는 조건도 함께 들어 있었습니다. 구조를 분리하면서 렌더링 로직은 이동했지만 이 조건 하나가 빠졌던 것입니다. 이후 기존 revision과 비교해 해당 조건을 그대로 navigation tree로 옮겼습니다. 이 경험 이후 navigation 관련 코드를 분리할 때는 route 구조뿐 아니라 permission, feature flag, settings, hidden condition, route metadata처럼 기존 component 안에 함께 들어 있던 정책도 같이 확인 하게 되었습니다. UI component 안에는 생각보다 화면을 그리는 코드 이상의 역할이 들어 있을 수 있었습니다. 사용하지 않는 설정 정리 작업 마지막에는 이번 변경에서 새로 추가하거나 수정한 항목을 기준으로 실제 reference를 다시 확인했습니다. 그 과정에서 더 이상 읽지 않는 route.meta.icon , 사용되지 않는 관련 type, breadcrumb에서 제거된 i18n key, 실제로 로드되지 않는 font family, 작업 중 의도하지 않게 변경된 dependency version 등을 정리했습니다. 반대로 token이나 CSS class를 단순히 코드 양을 줄이기 위해 제거하지는 않았습니다. 실제 reference가 존재하고 역할이 명확하다면 그대로 유지했습니다. 결국 정리의 기준은 파일 수나 코드 줄 수보다 남아 있는 코드가 실제 역할을 가지고 있는가 였습니다. 작은 기준을 공통 규칙으로 이번 작업은 별도의 디자인 시스템을 구축하기 위해 시작한 작업은 아니었습니다. Sidebar를 정리하다 보니 자연스럽게 여러 기준을 다시 생각하게 되었습니다. 같은 역할의 색상을 어떻게 관리할지, 어떤 값까지 token으로 만들지, hover와 focus를 어떻게 표현할지, click target과 visual surface를 어떻게 구분할지, Tailwind와 별도 CSS의 경계를 어디에 둘지 등을 하나씩 정리했습니다. 처음부터 많은 token이나 공통 component를 만드는 방식보다, 기존 UI에서 반복되고 있는 결정을 먼저 찾고 같은 상황에서 다음에도 같은 결정을 내릴 수 있도록 기준을 만드는 방식 으로 접근했습니다. 이번에는 Sidebar가 시작점이었지만, 앞으로 다른 화면을 정리할 때도 이 기준을 조금씩 적용하면서 범위를 확장해볼 수 있을 것 같습니다.
Балл: 54.4Уверенность: 49%
Подробнее