디자인 시스템을 복제하지 않는 방법: Polaris 구조와 CSS 테마로 다시 설계한 DEUX
디자인 시스템이 두세 개로 늘어나면 가장 먼저 보이는 차이는 색상과 모서리입니다. 그래서 새로운 시스템을 만들 때 기존 버튼과 카드의 색만 바꾸는 일로 시작하기 쉽습니다. 하지만 시간이 지나면 각 시스템 폴더에 Button, Input, Dialog, Chart 구현이 하나씩 생깁니다. 처음에는 독립성이 높아 보이지만, 접근성 수정이나 차트 계산 오류를 발견할 때마다 같은 코드를 여러 번 고쳐야 합니다.
DEUX는 이 문제를 해결하기 위해 구조를 다시 정리했습니다. Polaris가 컴포넌트의 DOM, 상태, 키보드 동작과 데이터 계산을 소유하고, Vega와 같은 추가 시스템은 CSS 변수와 제한된 스타일 규칙으로 같은 구조를 다르게 표현합니다.
이 글은 단순히 파일 수를 줄인 이야기가 아닙니다. 디자인 시스템에서 무엇을 공유하고 무엇을 달리해야 하는지, 그리고 차트처럼 복잡한 UI를 어떻게 테마화했는지 설명합니다.
복제된 컴포넌트가 만든 문제
기존에는 시스템마다 독립 패키지를 만들고 같은 이름의 컴포넌트를 각각 구현했습니다.
Polaris
Button.tsx
Card.tsx
LineChart.tsx
Vega
Button.tsx
Card.tsx
charts.tsx이 구조에는 세 가지 문제가 있었습니다.
첫째, 동작이 조금씩 달라졌습니다. 같은 Button이어도 전달되는 네이티브 속성, ref 대상, disabled 처리와 focus 표시가 달라질 수 있었습니다. 디자인 차이가 아니라 구현 편차였습니다.
둘째, 수정 비용이 시스템 수만큼 늘었습니다. 차트의 누적 합계 계산이나 모바일 폭 문제를 수정하면 모든 시스템 구현을 찾아 같은 패치를 적용해야 했습니다.
셋째, 소비 앱이 시스템을 바꾸기 어려웠습니다. 시각 스타일만 바꾸고 싶어도 import 경로와 props까지 달라졌습니다. 결과적으로 테마 선택이 컴포넌트 교체 작업이 되었습니다.
canonical contract부터 정리했습니다
코드보다 먼저 설계 기준을 수정했습니다. 여기서 canonical contract는 저장소에서 가장 우선하는 공식 설계 규칙을 뜻합니다. 구현과 문서가 충돌할 때 어떤 쪽을 고쳐야 하는지 판단하는 기준입니다.
새로운 계약은 다음과 같이 정리했습니다.
- 공통 컴포넌트의 DOM, 상태, 접근성과 데이터 계산은 Polaris가 소유합니다.
- 추가 디자인 시스템은 공통 컴포넌트를 다시 구현하지 않습니다.
- 시스템별 차이는 scoped CSS 변수와 스타일 속성으로 표현합니다.
- 구조가 정말 다른 컴포넌트만 명시적인 확장점이나 전용 패턴으로 추가합니다.
- 소비 앱은 공통 컴포넌트를 계속 import하고 콘텐츠 경계의
data-system만 바꿉니다.
이 기준을 문서에 먼저 기록한 이유는 간단합니다. 코드만 정리하면 다음 기능을 추가하는 과정에서 복제 구조가 다시 생길 수 있기 때문입니다.
최종 구조
공통 동작은 @duolabs-deux/ui가 제공합니다.
import { Card, LineChart, StatCard } from "@duolabs-deux/ui";Vega 스타일이 필요한 콘텐츠 경계에는 시스템 속성을 지정합니다.
export function AnalyticsPanel() {
return (
<section data-system="vega">
<Card title="일별 실행 추이">
<LineChart series={series} labels={labels} />
</Card>
</section>
);
}CSS에서는 Vega가 사용하는 시맨틱 토큰과 차트 표현 토큰을 정의합니다.
[data-system="vega"] {
--background: #fafafa;
--surface: #ffffff;
--foreground: #111113;
--border: #dedee3;
--chart-series-1: #18181b;
--chart-series-2: #64748b;
--chart-grid: color-mix(in srgb, var(--foreground) 10%, transparent);
--chart-line-width: 1.5;
--chart-bar-radius: 2px;
--chart-area-opacity: 0.08;
}Polaris와 Vega는 같은 React 트리와 같은 접근성 정보를 사용합니다. 바뀌는 것은 색상, 간격, 반경, 그림자와 차트 선의 표현입니다.
차트에서는 계산과 표현을 분리했습니다
차트는 테마 전환에서 가장 까다로운 부분이었습니다. 단순한 배경색 변경만으로는 시스템별 인상이 충분히 달라지지 않지만, 시스템마다 SVG를 다시 작성하면 계산 오류가 반복됩니다.
그래서 차트를 두 층으로 나눴습니다.
Polaris가 담당하는 부분
- 값 범위와 축 눈금 계산
- 선, 막대와 원호의 좌표
- 빈 데이터 처리
- 반응형 폭과 가로 스크롤 기준
- 범례와 값 포맷
figure,title, 접근 가능한 이름- 음수, 0, 누적 합계와 100% 경계 처리
테마가 담당하는 부분
- 계열별 색상
- 격자선과 기준선의 농도
- 선 굵기와 영역 채움 투명도
- 막대 반경과 간격
- 점 크기와 강조 효과
- 트랙 색상
- 카드 경계와 그림자
예를 들어 LineChart의 path 계산은 한 번만 수행합니다. Polaris는 부드러운 선과 여유 있는 색상 대비를 사용할 수 있고, Vega는 얇은 선, 낮은 채도의 계열색과 촘촘한 격자를 사용할 수 있습니다. 하지만 두 차트의 데이터 해석과 키보드·스크린 리더 결과는 같습니다.
차트가 Card를 다시 만들지 않도록 했습니다
기존 차트 중에는 자체 배경, 테두리와 그림자를 포함한 것이 있었습니다. 이 차트를 Card 안에 넣으면 테두리가 두 겹이 되고 패딩도 중복되었습니다.
차트는 이제 시각화만 담당합니다.
<Card title="공급자 비중" description="최근 14일 실행 수 기준">
<DonutChart segments={segments} />
</Card>Card는 제목, 설명, 배경과 패딩을 소유합니다. DonutChart는 원호, 중앙 값과 범례를 소유합니다. 작은 StatCard 안의 Sparkline도 같은 원칙을 따릅니다.
이 분리는 레이아웃을 단순하게 만들고, 대시보드의 카드 밀도를 바꿀 때 차트 내부 코드를 수정하지 않아도 되게 합니다.
CSS만으로 바꾸기 어려운 차이
모든 차이를 CSS로 해결하려고 하면 오히려 API가 복잡해집니다. DOM 구조나 사용자 과업 자체가 달라지는 경우에는 별도 패턴이 필요합니다.
다음 기준으로 예외를 판단했습니다.
- 접근성 role과 키보드 동작이 같다면 공통 구현을 유지합니다.
- 같은 데이터가 같은 의미로 표시된다면 차트 계산을 공유합니다.
- 차이가 색상, 간격, 선, 반경과 배치 밀도라면 CSS 토큰으로 처리합니다.
- 필요한 정보와 상호작용 순서가 다르면 공통 컴포넌트에 명시적인 slot을 추가합니다.
- 특정 제품의 데이터 fetching, 권한과 라우팅이 필요하면 디자인 시스템이 아니라 앱이 소유합니다.
이 기준을 통과하지 못한 전용 구현은 디자인 시스템 변형이 아니라 별도 컴포넌트나 앱 패턴으로 분류합니다.
소비 앱에서는 무엇이 달라졌을까요?
소비 앱은 컴포넌트 import를 교체하지 않습니다. 기본 셸과 공통 동작은 그대로 유지하고, 실제 시스템 스타일이 필요한 콘텐츠에만 scope를 지정합니다.
@import "@duolabs-deux/ui/tokens.css";
@import "@duolabs-deux/vega-ui/scope.css";<AppShell>
<main data-system="vega">
<AnalyticsDashboard />
</main>
</AppShell>알 수 없는 시스템 ID나 준비되지 않은 테마가 선택되면 Polaris 토큰을 사용합니다. 일부 버튼만 다른 시스템으로 섞지 않고 콘텐츠 경계 전체가 같은 스타일 규칙을 사용합니다.
리팩터링 순서
전환은 다음 순서로 진행했습니다.
- 공식 설계 계약에서 공통 구조와 테마의 책임을 명확히 했습니다.
- 공통 컴포넌트의 native props, ref, 상태와 접근성을 정리했습니다.
- 차트 계산과 SVG 구조를 Polaris 구현으로 통합했습니다.
- 하드코딩된 선 굵기, 색상, 반경과 투명도를 CSS 변수로 옮겼습니다.
- Vega를 tokens, scoped CSS와 manifest 중심의 테마 패키지로 바꿨습니다.
- 소비 앱의 시스템별 컴포넌트 import를 공통 import로 교체했습니다.
- 타입 검사, 문서 카탈로그, 프로덕션 빌드와 시각 회귀를 확인했습니다.
실험 상태였던 Vega 차트 export는 1.0 major에서 제거하고 공통 import로 이동하는 방법을 함께 제공했습니다. 안정 상태의 소비 API를 제거할 때는 deprecation 기간과 대체 경로를 먼저 제공합니다.
얻은 결과
가장 큰 변화는 코드 양보다 수정의 방향입니다.
- 접근성 수정은 공통 컴포넌트 한 곳에 반영됩니다.
- 차트 계산 오류를 시스템별로 반복 수정하지 않습니다.
- 새로운 시스템을 추가할 때 TSX 복사보다 토큰 설계에 집중합니다.
- 소비 앱은 import를 유지한 채 콘텐츠 경계의 스타일을 바꿀 수 있습니다.
- 시스템별 차이는 구현 편차가 아니라 의도된 시각 규칙으로 남습니다.
디자인 시스템의 다양성은 같은 컴포넌트를 여러 번 만드는 데서 나오지 않습니다. 안정적인 구조 위에서 색상, 밀도, 움직임과 데이터 표현 규칙을 일관되게 바꾸는 데서 나옵니다.
Polaris는 뼈대를 담당하고 Vega는 분석 화면에 맞는 정밀한 시각 언어를 담당합니다. 두 역할을 분리하면서 디자인의 차이는 유지하고 유지보수 비용은 줄일 수 있었습니다.
함께 읽기
- 데스크톱 AI 화면을 모바일에서 과감히 제거한 이유데스크톱에서 잘 작동하는 AI 인터페이스를 모바일 화면에 그대로 줄이면 기능은 남지만 경험은 쉽게 무너집니다. 사이드바, 대화, 참고 문헌을 동시에 보여주는 3단 구조는 넓은 화면에서는 강력하지만 작은 화면에서는 탐색과 스크롤이 서로 경쟁합니다.
- 디자인 시스템을 씌우기 전에 레이아웃 틀부터 맞춰야 하는 이유같은 디자인 시스템을 사용하는데도 어떤 화면은 정돈되어 보이고, 어떤 화면은 미묘하게 어긋나 보일 때가 있습니다. 색상과 버튼 모양이 같아도 페이지 폭, 여백, 제목 위치, 입력 필드의 구성이 제각각이면 전체 인상은 쉽게 흐트러집니다.
- 디자인 시안을 웹으로 옮기는 순서: 시맨틱 HTML·토큰·컴포넌트디자인 시안을 웹으로 옮길 때 핵심은 픽셀을 그대로 복사하는 것이 아니라, 콘텐츠의 의미와 반복 규칙을 브라우저가 이해할 수 있는 구조로 번역하는 것이다. 이 글에서는 시맨틱 HTML, 디자인 토큰, 컴포넌트, 반응형 규칙을 이용해 시안을 유지보수 가능한 코드로 구현하는 순서를 살펴본다.
- Google 번역을 켰더니 React 앱이 오류 화면으로 바뀐 이유한국어, 영어, 일본어, 중국어를 직접 제공하는 React 랜딩 페이지에서 예상하지 못한 문제가 생겼습니다. Chrome이 띄운 "이 페이지를 번역하시겠습니까?" 제안을 수락하면 번역이 시작되는 듯하다가, 잠시 뒤 사이트의 "일시적인 오류가 발생했습니다" 화면으로 바뀌었습니다.
- iOS Safari 모바일 버그 3종 해결기: 헤더 잘림 · 흰 화면 · 가로 넘침같은 날 확인된 세 증상은 원인이 서로 달랐으며, 모두 해당 테스트 환경에서 데스크톱 Chrome과 다르게 나타난 WebKit 관련 동작이었습니다.