디자인 토큰 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에 하드코딩하면 무용지물이거든요.