RSS

디자인 시스템을 복제하지 않는 방법: 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는 저장소에서 가장 우선하는 공식 설계 규칙을 뜻합니다. 구현과 문서가 충돌할 때 어떤 쪽을 고쳐야 하는지 판단하는 기준입니다.

새로운 계약은 다음과 같이 정리했습니다.

  1. 공통 컴포넌트의 DOM, 상태, 접근성과 데이터 계산은 Polaris가 소유합니다.
  2. 추가 디자인 시스템은 공통 컴포넌트를 다시 구현하지 않습니다.
  3. 시스템별 차이는 scoped CSS 변수와 스타일 속성으로 표현합니다.
  4. 구조가 정말 다른 컴포넌트만 명시적인 확장점이나 전용 패턴으로 추가합니다.
  5. 소비 앱은 공통 컴포넌트를 계속 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 토큰을 사용합니다. 일부 버튼만 다른 시스템으로 섞지 않고 콘텐츠 경계 전체가 같은 스타일 규칙을 사용합니다.

리팩터링 순서

전환은 다음 순서로 진행했습니다.

  1. 공식 설계 계약에서 공통 구조와 테마의 책임을 명확히 했습니다.
  2. 공통 컴포넌트의 native props, ref, 상태와 접근성을 정리했습니다.
  3. 차트 계산과 SVG 구조를 Polaris 구현으로 통합했습니다.
  4. 하드코딩된 선 굵기, 색상, 반경과 투명도를 CSS 변수로 옮겼습니다.
  5. Vega를 tokens, scoped CSS와 manifest 중심의 테마 패키지로 바꿨습니다.
  6. 소비 앱의 시스템별 컴포넌트 import를 공통 import로 교체했습니다.
  7. 타입 검사, 문서 카탈로그, 프로덕션 빌드와 시각 회귀를 확인했습니다.

실험 상태였던 Vega 차트 export는 1.0 major에서 제거하고 공통 import로 이동하는 방법을 함께 제공했습니다. 안정 상태의 소비 API를 제거할 때는 deprecation 기간과 대체 경로를 먼저 제공합니다.

얻은 결과

가장 큰 변화는 코드 양보다 수정의 방향입니다.

  • 접근성 수정은 공통 컴포넌트 한 곳에 반영됩니다.
  • 차트 계산 오류를 시스템별로 반복 수정하지 않습니다.
  • 새로운 시스템을 추가할 때 TSX 복사보다 토큰 설계에 집중합니다.
  • 소비 앱은 import를 유지한 채 콘텐츠 경계의 스타일을 바꿀 수 있습니다.
  • 시스템별 차이는 구현 편차가 아니라 의도된 시각 규칙으로 남습니다.

디자인 시스템의 다양성은 같은 컴포넌트를 여러 번 만드는 데서 나오지 않습니다. 안정적인 구조 위에서 색상, 밀도, 움직임과 데이터 표현 규칙을 일관되게 바꾸는 데서 나옵니다.

Polaris는 뼈대를 담당하고 Vega는 분석 화면에 맞는 정밀한 시각 언어를 담당합니다. 두 역할을 분리하면서 디자인의 차이는 유지하고 유지보수 비용은 줄일 수 있었습니다.