4 min read

디자인 토큰 3계층 구조와 MFA 환경에서의 공유 전략

Module Federation으로 여러 마이크로앱을 운영하고 있는데, 디자이너가 색상을 바꾸면 모든 앱에 반영되어야 합니다. 초기에는 각 앱이 SCSS 변수를 복붙해서 쓰고 있었고, 당연히 싱크가 안 맞았습니다.

디자인 토큰을 JSON으로 중앙 관리하고, CSS Variables로 변환해서 공유 모듈을 통해 전체 앱에 배포하는 구조를 만들었습니다.


3-layer 토큰 구조

Reference (원시값)  →  System (의미론적 조합)  →  UI (사용처별 매핑)

Reference — 원시값

--ref-color-neutral-55: #8c8c8c;
--ref-color-blue-50: #4255d5;
--ref-fontsize-14px: 14px;
--ref-spacing-8: 8px;

디자이너가 Figma에서 정의한 원시 팔레트입니다. 이 레이어를 컴포넌트에서 직접 쓰지 않습니다.

System — 의미론적 조합

--sys-color-primary-base: var(--ref-color-blue-50);
--sys-color-text-secondary: var(--ref-color-neutral-55);
--sys-typography-text-sm-bold: 700 var(--ref-fontsize-12px)/var(--ref-lineheight-16px) Pretendard;

reference를 조합해서 의미를 부여합니다. primary가 뭔지, text-secondary가 뭔지 여기서 결정됩니다.

UI — 사용처별 매핑

--ui-color-text-base: var(--sys-color-text-primary);
--ui-color-text-subtle: var(--sys-color-text-secondary);
--ui-typography-body-medium-bold: var(--sys-typography-text-sm-bold);

컴포넌트가 실제로 참조하는 레이어입니다. 같은 system 토큰이 여러 UI 토큰으로 매핑될 수 있고, 나중에 특정 사용처만 바꾸고 싶을 때 system을 건드리지 않아도 됩니다.


왜 3단계인가

2단계(원시값 → 컴포넌트)로 해도 동작은 합니다. 하지만 이런 상황에서 차이가 납니다:

  • 디자이너가 primary 색상을 변경하면 → system 레이어 한 줄 수정으로 전체 반영
  • 특정 화면의 body 텍스트만 더 진하게 하고 싶으면 → UI 레이어에서만 override
  • 다크 모드 추가하면 → system 레이어를 통째로 교체 (reference, UI는 그대로)

레이어가 하나 더 있으면 변경 범위를 정확히 제어할 수 있습니다.


DesignText 컴포넌트 — 토큰을 소비하는 방식

SCSS에서 font-size나 color를 직접 지정하지 않습니다. 텍스트는 전부 DesignText 컴포넌트를 통합니다:

<DesignText role="body" size="medium" weight="bold" type="base" :text="label" />

내부에서 이렇게 매핑됩니다:

.design-text {
  font: var(--ui-typography-#{$role}-#{$size}-#{$weight});
  color: var(--ui-color-text-#{$type});
}

props 조합이 곧 토큰 경로입니다. role=body, size=medium, weight=bold--ui-typography-body-medium-bold. 타입스크립트 타입으로 유효한 조합만 허용하니까 존재하지 않는 토큰을 참조할 일이 없습니다.

팀 규칙으로 SCSS에서 font-size, font-weight, color를 직접 쓰면 리뷰에서 막습니다. DesignText를 강제하면 토큰 체계에서 벗어나는 걸 구조적으로 방지할 수 있습니다.


Module Federation에서의 공유

토큰 CSS와 공통 컴포넌트는 shared 패키지에 있고, Federation의 exposes로 다른 앱에 노출됩니다. 각 서비스 앱은 이걸 import해서 씁니다.

// shared의 federation.config.ts
exposes: {
  "./components": "./src/components/index.ts",
  "./composables": "./src/composables/index.ts",
}
// analytics 앱에서
import { DesignText, DesignButton } from "shared/components";

shared를 CDN으로 빼면 전체 앱에 동일한 토큰이 적용되고, 로컬로 띄우면 수정사항을 즉시 확인할 수 있습니다.


Figma → 토큰 JSON

Figma에서 토큰을 export하면 319KB짜리 JSON이 나옵니다. color, typography, spacing, sizing, border-radius, opacity, box-shadow 등 여러 카테고리로 나뉘어 있고, 이걸 CSS Variables로 변환합니다.

{
  "color": {
    "ref": {
      "blue": { "50": { "value": "#4255d5" } }
    },
    "sys": {
      "primary": { "base": { "value": "{color.ref.blue.50}" } }
    }
  }
}

{color.ref.blue.50} 같은 참조 문법으로 레이어 간 의존성을 표현합니다. 빌드 시 이걸 CSS var() 참조로 변환합니다.


Storybook 연동

공통 컴포넌트마다 story 파일이 있습니다. Storybook이 rsbuild 기반으로 돌아가는데, Module Federation alias를 해결하기 위해 커스텀 설정이 필요했습니다:

// .storybook/main.ts
rsbuildFinal(config) {
  config.resolve.alias = {
    "shared/components": path.resolve(__dirname, "../src/components"),
    "shared/composables": path.resolve(__dirname, "../src/composables"),
  };
}

Federation 런타임이 없는 Storybook에서도 컴포넌트가 동작하게 경로를 직접 매핑합니다.


마무리

토큰 시스템 자체는 Material Design 3의 구조를 참고했습니다. 3계층이 과하다고 느낄 수 있는데, 여러 앱에서 일관성을 유지하면서도 유연하게 변경하려면 이 정도 레이어가 필요했습니다.

DesignText 같은 소비 패턴을 팀 규칙으로 강제하는 게 실질적으로 가장 효과가 컸습니다. 토큰을 아무리 잘 만들어도 직접 SCSS에 하드코딩하면 무용지물이거든요.


참고