03 — Multi-Brand Systems
이 문서가 답하는 질문: 한 회사가 여러 제품(Atlassian의 Jira/Confluence/Trello, Shopify의 Admin/Storefront/Polaris)을 운영할 때 동일한 컴포넌트 라이브러리 위에 서로 다른 브랜드 정체성을 입히려면 토큰을 어떻게 나누어야 하는가? 그리고 다크모드와 브랜드는 직교적으로 결합될 수 있는가? 한 줄 답 (Pyramid Top): 정답은 “
primitive는 모든 브랜드가 공유하는 거대한 팔레트,semantic은 브랜드별 다른 alias,component는 완전히 무지” 의 3-tier —data-brand×data-theme이 2차원 좌표로 직교적으로 결합한다. Atlassian의 ADS, Shopify의 Polaris, IBM의 Carbon이 모두 이 패턴을 따른다.
Why — 왜 존재하는가
큰 조직은 한 회사 안에 철학과 색조가 다른 제품들을 동시에 운영한다.
| 회사 | 브랜드 1 | 브랜드 2 | 브랜드 3 |
|---|---|---|---|
| Atlassian | Jira (파란계열, 워크플로) | Confluence (보라, 문서) | Trello (파스텔, 친근) |
| Shopify | Admin (그린, 효율) | Storefront (가맹점별 themable) | Polaris docs |
| Material Web (기본) | YouTube (빨강) | Workspace (블루) | |
| IBM | Carbon (블루 70) | Watson (보라) | Cloud (그라데이션) |
같은 컴포넌트(Button, Input, Dialog)를 제품마다 다시 짜면 다음이 발생한다:
- 같은 버그를 3번 고친다.
- 접근성 표준이 제품마다 다르다.
- 디자인 시스템 팀이 제품 수만큼 분열된다.
| 풀려는 문제 | 단순 해법 | 한계 |
|---|---|---|
| 제품마다 다른 색 | 제품별 컴포넌트 라이브러리 분리 | 코드 N벌, 유지보수 불가 |
| 공통 컴포넌트 재사용 | <Button color="purple"> prop | 100개 prop, API 폭발 |
| 다크모드 × 브랜드 | 2×N 빌드 산출물 | 번들 N배, 캐시 무효화 |
해법은 2차원 좌표 시스템 — 컴포넌트는 brand도 theme도 모르고, HTML이 <html data-brand="jira" data-theme="dark"> 두 축을 박는다. CSS는 그 좌표에서 토큰을 결정한다.
How — 어떻게 동작하는가
핵심: primitive는 팔레트 전체다. Jira만 쓸 거니까 blue만 두는 게 아니라, blue / purple / green / teal 모두 둔다. 어차피 CSS variables는 사용되지 않는 값에 비용이 없다 (Custom Property는 cascade 시점에 lazy 평가).
semantic이 브랜드별로 어떤 primitive를 가리킬지만 결정한다.
:root {
/* primitive — 모든 브랜드 공통 */
--blue-500: #0052cc;
--purple-500: #6554c0;
--teal-400: #00b8d9;
}
[data-brand="jira"] {
--color-accent: var(--blue-500);
--color-fg-on-accent: #ffffff;
}
[data-brand="confluence"] {
--color-accent: var(--purple-500);
--color-fg-on-accent: #ffffff;
}
[data-brand="trello"] {
--color-accent: var(--teal-400);
--color-fg-on-accent: #172b4d;
}
/* brand × theme 교차 — 직교적 결합 */
[data-brand="jira"][data-theme="dark"] {
--color-accent: #4c9aff; /* 다크에서 같은 brand인데 더 밝은 blue */
}컴포넌트는 한 줄도 안 바뀐다:
.button-primary {
background: var(--color-accent);
color: var(--color-fg-on-accent);
}What — 구체 사양 / 수치 / 예시
사실상 표준 케이스 4선
| 회사 | 시스템 | 다중 브랜드 처리 | 메커니즘 |
|---|---|---|---|
| Atlassian | ADS | @atlaskit/tokens 패키지가 product별 token map | data-theme="light" data-brand="jira" |
| Shopify | Polaris | Admin은 단일, Storefront는 가맹점이 자기 토큰 주입 | CSS variables 런타임 override |
| IBM | Carbon | white / g10 / g90 / g100 4 테마 | @carbon/themes SCSS map |
| Microsoft | Fluent 2 | Web / Windows / Teams 각각 다른 token preset | FluentProvider React context |
Atlassian의 data-theme × data-color-mode 패턴
{/* 라이트 모드의 Jira */}
<html data-theme="light:light dark:dark" data-color-mode="light">
{/* 다크 모드의 Confluence */}
<html data-theme="light:light dark:dark" data-color-mode="dark">ADS는 @atlaskit/tokens 패키지가 토큰 이름을 변수로 export하고, 빌드 시 data-theme selector로 묶인 CSS를 생성한다. 컴포넌트 코드는:
import { token } from '@atlaskit/tokens';
const Button = styled.button`
background: ${token('color.background.accent.blue.bolder')};
color: ${token('color.text.inverse')};
`;token() 함수는 var(--ds-background-accent-blue-bolder) 같은 CSS variable 문자열을 리턴한다. 브랜드 분기는 CSS 측에 위임.
Shopify Storefront — 런타임 사용자 정의
Shopify Storefront는 가맹점이 자기 브랜드 색을 런타임에 주입한다. 컴포넌트는 token이름만 알고, 가맹점이 CSS variable을 override.
/* Shopify Polaris (기본) */
:root {
--p-color-bg-fill-brand: #008060; /* Shopify green */
}
/* 가맹점 ABC의 storefront */
:root {
--p-color-bg-fill-brand: #ff6b35; /* 가맹점 ABC의 오렌지 */
}가맹점은 <style> 태그 한 장만 inject — 컴포넌트는 수천 가맹점이 다른 색을 쓰는 줄 모른다.
멀티 브랜드 토큰 정의 (DTCG 확장)
{
"primitive": {
"blue": { "500": { "$value": "#0052cc", "$type": "color" } },
"purple": { "500": { "$value": "#6554c0", "$type": "color" } },
"teal": { "400": { "$value": "#00b8d9", "$type": "color" } }
},
"semantic": {
"color": {
"accent": {
"$value": "{primitive.blue.500}",
"$type": "color",
"$extensions": {
"design-system.brands": {
"jira": "{primitive.blue.500}",
"confluence": "{primitive.purple.500}",
"trello": "{primitive.teal.400}"
},
"design-system.brands.dark": {
"jira": "#4c9aff",
"confluence": "#998dd9",
"trello": "#00c7e6"
}
}
}
}
}
}Panda CSS 멀티 브랜드 설정
// panda.config.ts
import { defineConfig } from '@pandacss/dev';
export default defineConfig({
conditions: {
dark: '[data-theme=dark] &',
light: '[data-theme=light] &',
jira: '[data-brand=jira] &',
confluence: '[data-brand=confluence] &',
trello: '[data-brand=trello] &',
jiraDark: '[data-brand=jira][data-theme=dark] &',
},
theme: {
semanticTokens: {
colors: {
'color.accent': {
value: {
base: '{colors.blue.500}',
_confluence: '{colors.purple.500}',
_trello: '{colors.teal.400}',
_jiraDark: '#4c9aff',
},
},
},
},
},
});React 측 Provider
// app/layout.tsx
'use client';
import { useEffect, useState } from 'react';
type Brand = 'jira' | 'confluence' | 'trello';
type Theme = 'light' | 'dark';
export function BrandThemeProvider({
brand,
theme,
children,
}: {
brand: Brand;
theme: Theme;
children: React.ReactNode;
}) {
useEffect(() => {
const html = document.documentElement;
html.setAttribute('data-brand', brand);
html.setAttribute('data-theme', theme);
}, [brand, theme]);
return <>{children}</>;
}브랜드 토큰 경계는 어디까지인가?
| 토큰 카테고리 | 브랜드별로 다른가? | 비고 |
|---|---|---|
color.accent / color.brand | YES | 본질 |
color.bg.* / color.fg.* | 보통 NO | 라이트/다크 분기만 |
font.family.brand | 자주 YES | Confluence는 serif 가산 |
font.family.body | 보통 NO | 시스템 폰트 공유 |
space.* | NO | 4/8 grid는 보편 |
radius.* | 가끔 YES | Trello는 친근 → 큰 radius |
shadow.* | 보통 NO | elevation은 공통 |
원칙: 브랜드 정체성과 관련된 것만 분기. 4px grid나 elevation 같은 디자인 시스템의 보편 원칙은 공유.
What-if — 잘못 쓰면 어떻게 깨지는가
- 함정 1 — 브랜드별 빌드 산출물 N개: Webpack 4 시대의 흔한 실수.
npm run build:jirabuild:confluencebuild:trello→ 번들 3개 캐시 3개. CSS variables 런타임 swap이 정답. - 함정 2 — 컴포넌트에
brandprop 추가:<Button brand="jira">. 100개 컴포넌트에 prop이 전염되고, 새 브랜드 추가 시 100개 수정. HTML attribute 한 곳에서만 처리. - 함정 3 — brand × theme 곱집합을 모두 토큰으로 정의:
jira-lightjira-darkconfluence-lightconfluence-dark… 8개 토큰 세트. 새 차원(예: 고대비)이 추가되면 16개. 대신 직교적 선택자([data-brand=jira][data-theme=dark])로 cascade에 위임. - 함정 4 — primitive를 브랜드별로 다르게: “Confluence의 blue는 좀 더 보랏빛이라…” →
blue.500을 brand별로 override. 이러면 primitive가 아니다.blue.500은 한 값. Confluence는purple-blue.500같은 별도 primitive를 정의하거나, semantic이purple.500을 가리키게 한다. - 함정 5 — Storefront에서 사용자 토큰 sanitize 안 함: Shopify처럼 사용자가 CSS variable을 주입하는 경우 XSS 검증 필수.
--bg-brand: red; } body { display: none; --x:같은 입력 차단. - 함정 6 —
data-brand변경 시 transition 폭주: 100개 요소가 동시에 색 transition. 토글 시 transition 일시 정지 + 한 프레임 후 복구.
Insight — 흥미로운 이야기
Atlassian의 Token UI Kit (2022) 는 한 디자이너의 내부 RFC에서 시작했다 — “Jira, Confluence, Trello가 같은 Button을 쓰는데 왜 디자이너가 색을 따로 정의해야 하는가”
당시 Atlassian은 세 제품이 각자 React 컴포넌트를 fork해서 색만 갈아 끼우고 있었다. 디자이너 Mitchell Hamilton이 작성한 짧은 문서가 핵심을 짚었다: “우리는 색의 이름이 아니라 색의 역할을 토큰화해야 한다.” blue-500 대신 color.text.accent, purple-500 대신 color.background.brand.bold — 같은 역할 이름이 브랜드별 다른 hex를 가리킨다.
이게 왜 혁명이었나? 디자이너가 Figma에서 text/accent variable을 쓰면, 컴포넌트는 CSS에서 var(--text-accent)를 쓴다. 디자이너와 개발자 사이의 번역 단계가 사라진다. 그리고 새 브랜드를 추가할 때 컴포넌트는 손대지 않는다 — 토큰 맵만 새 brand 키를 추가.
Shopify의 반전: Shopify Polaris는 admin은 단일 브랜드인데도 토큰 시스템을 멀티 브랜드처럼 설계했다. 이유는 Storefront. 수천만 가맹점이 자기 색을 주입해야 하므로, primitive와 semantic이 분리된 구조가 필수였다. Admin도 같은 시스템 위에 올라가 있을 뿐. “쓰는 곳이 하나라도, 멀티 브랜드 구조로 설계하면 미래의 N번째 브랜드가 공짜가 된다.”
또 하나의 사례: GitHub의 data-color-mode × data-light-theme × data-dark-theme 는 3차원 직교다. 사용자가 “라이트는 색맹 친화로, 다크는 dimmed로”를 각각 독립적으로 고를 수 있다. 즉, brand만 N차원으로 늘릴 게 아니라 접근성 모드도 직교 축이 될 수 있다.
마지막 통찰: **“브랜드 = 토큰 맵”**이 결국 진짜 정의다. 로고가 브랜드가 아니라 어떤 색이 accent를 맡느냐가 브랜드. 디자인 시스템 입장에서 브랜드는 하나의 함수다 — brand: (token_role) → primitive.
요약
- 멀티 브랜드는
data-brand×data-theme의 2차원(또는 N차원) 직교 좌표다. primitive는 모든 브랜드가 공유,semantic만 브랜드별 다른 primitive를 가리킨다,component는 무지.- Atlassian ADS · Shopify Polaris · IBM Carbon · GitHub Primer 모두 같은 패턴.
- 컴포넌트에
brandprop을 추가하지 말 것 — HTML attribute 한 곳에서만. - Storefront처럼 사용자가 토큰을 주입하는 경우 XSS sanitize 필수.