Token Tiers — primitive / semantic / component
이 문서가 답하는 질문: 토큰을 왜 한 층이 아니라 3층으로 나누는가. 각 층은 무엇을 책임지고, 어디서 어디로 alias가 흐르는가. 한 줄 답 (Pyramid Top): 토큰을
primitive(blue.500) → semantic(primary) → component(button.bg.default)의 3-tier로 나누면, 각 변경의 반경이 한 층에서 닫힌다 — 다크모드는 semantic 층만, 컴포넌트 hover는 component 층만, 브랜드 리브랜딩은 primitive 층만 수정한다.
Why — 한 층 토큰의 비극
만약 토큰을 한 층에만 두면 어떻게 되는가? color.button.background = "#3b82f6"처럼 결정과 값이 한 줄에 묶인다. 다음 4가지 변경 시나리오를 따라가보자.
| 시나리오 | 한 층 시스템의 비용 | 3-tier 시스템의 비용 |
|---|---|---|
브랜드 색을 살짝 더 푸르게 (#3b82f6 → #2563eb) | 100개 토큰의 hex 일일이 수정 | color.blue.500 한 줄만 수정 |
| 다크모드 추가 | 모든 컴포넌트 토큰에 light/dark 두 벌 | semantic 층만 light/dark, primitive·component 그대로 |
| 버튼 hover 상태 도입 | 어디에 추가할지 막연 | button.bg.hover 하나 추가 |
| Brand B 멀티 브랜드 | 시스템 통째 복제 | primitive·semantic만 분기, component는 공유 |
핵심은 **“변경의 반경을 한 층에 가두는 것”**이다. 3-tier는 그 격리를 강제한다.
How — 각 층의 책임과 alias 흐름
3개 층의 정의
| 층 | 이름 | 무엇을 담는가 | 누가 정의하는가 | 변경 빈도 |
|---|---|---|---|---|
| Tier 1 | Primitive (= “core” / “global” / “reference”) | 의미 없는 raw 값. 색 팔레트, 스케일 | 디자인 시스템 팀 | 매우 낮음 (분기/연 단위) |
| Tier 2 | Semantic (= “alias” / “system”) | 역할에 붙인 이름. 테마에 따라 다른 primitive를 가리킴 | 디자인 시스템 팀 + 프로덕트 디자이너 | 낮음 (월 단위) |
| Tier 3 | Component (= “scope” / “local”) | 특정 컴포넌트 슬롯. semantic을 한 단계 더 좁힘 | 컴포넌트 작성자 | 중간 (컴포넌트별) |
alias의 방향
규칙:
- 컴포넌트 토큰은 primitive를 직접 참조하지 않는다 — 반드시 semantic을 거친다.
- semantic 토큰은 컴포넌트 토큰을 참조하지 않는다 — 방향은 위에서 아래로만.
- primitive는 alias 하지 않는다 — 항상 raw 값.
What — 실전 토큰 정의 (DTCG JSON)
Tier 1: Primitive (color scale)
{
"color": {
"blue": {
"50": { "$value": "#eff6ff", "$type": "color" },
"100": { "$value": "#dbeafe", "$type": "color" },
"400": { "$value": "#60a5fa", "$type": "color" },
"500": { "$value": "#3b82f6", "$type": "color" },
"600": { "$value": "#2563eb", "$type": "color" },
"900": { "$value": "#1e3a8a", "$type": "color" }
},
"gray": {
"50": { "$value": "#f9fafb", "$type": "color" },
"100": { "$value": "#f3f4f6", "$type": "color" },
"500": { "$value": "#6b7280", "$type": "color" },
"900": { "$value": "#111827", "$type": "color" },
"950": { "$value": "#030712", "$type": "color" }
}
}
}blue.500이라는 이름은 결정이 아니라 팔레트 위치다. “이 색을 어디에 쓸지”는 아직 정해지지 않았다.
Tier 2: Semantic (light + dark)
{
"color": {
"primary": {
"$value": "{color.blue.500}",
"$type": "color",
"$description": "Primary brand color — used for CTAs, links, focus rings"
},
"primary-hover": {
"$value": "{color.blue.600}",
"$type": "color"
},
"bg": {
"surface": {
"$value": "{color.gray.50}",
"$type": "color",
"$description": "Default page surface"
},
"muted": {
"$value": "{color.gray.100}",
"$type": "color"
}
},
"fg": {
"default": {
"$value": "{color.gray.900}",
"$type": "color"
},
"muted": {
"$value": "{color.gray.500}",
"$type": "color"
}
}
}
}다크모드용 별도 파일 tokens/color.semantic.dark.json:
{
"color": {
"primary": { "$value": "{color.blue.400}", "$type": "color" },
"primary-hover": { "$value": "{color.blue.300}", "$type": "color" },
"bg": {
"surface": { "$value": "{color.gray.950}", "$type": "color" },
"muted": { "$value": "{color.gray.900}", "$type": "color" }
},
"fg": {
"default": { "$value": "{color.gray.50}", "$type": "color" },
"muted": { "$value": "{color.gray.400}", "$type": "color" }
}
}
}여기가 핵심이다. 다크모드는 semantic 층의 alias 대상만 바뀐다. Primitive와 component 층은 전혀 건드리지 않는다.
Tier 3: Component
{
"button": {
"bg": {
"default": { "$value": "{color.primary}", "$type": "color" },
"hover": { "$value": "{color.primary-hover}", "$type": "color" },
"disabled":{ "$value": "{color.fg.muted}", "$type": "color" }
},
"fg": {
"default": { "$value": "{color.gray.50}", "$type": "color" }
},
"border": {
"default": { "$value": "{color.primary}", "$type": "color" }
}
},
"card": {
"bg": { "$value": "{color.bg.surface}", "$type": "color" },
"border": { "$value": "{color.fg.muted}", "$type": "color" }
}
}빌드 결과 — CSS variables
Style Dictionary가 위 3개 파일을 합성한 결과 (light.css):
:root {
/* Tier 1: primitive (변하지 않음) */
--color-blue-400: #60a5fa;
--color-blue-500: #3b82f6;
--color-blue-600: #2563eb;
--color-gray-50: #f9fafb;
--color-gray-900: #111827;
/* Tier 2: semantic (light 모드) */
--color-primary: var(--color-blue-500);
--color-primary-hover: var(--color-blue-600);
--color-bg-surface: var(--color-gray-50);
--color-fg-default: var(--color-gray-900);
/* Tier 3: component */
--button-bg-default: var(--color-primary);
--button-bg-hover: var(--color-primary-hover);
--card-bg: var(--color-bg-surface);
}
[data-theme="dark"] {
/* Tier 2만 재정의 — Tier 1, 3은 건드리지 않음 */
--color-primary: var(--color-blue-400);
--color-primary-hover: var(--color-blue-300);
--color-bg-surface: var(--color-gray-950);
--color-fg-default: var(--color-gray-50);
}관찰: --button-bg-default는 var(--color-primary)를 가리키므로 다크모드 시 자동으로 blue.400을 받는다. 컴포넌트 토큰 정의를 전혀 수정하지 않았는데도 다크모드가 작동한다.
What — 멀티 브랜드 시나리오
3-tier는 다크모드 외에도 멀티 브랜드에 그대로 적용된다.
같은 컴포넌트 코드가 서로 다른 브랜드에서 알맞은 색으로 렌더된다. component 층의 정의는 단 한 벌이다.
What-if — 3-tier를 잘못 다루면
함정 1: 컴포넌트가 primitive를 직접 참조
// ❌ 나쁨
{
"button": {
"bg": { "$value": "{color.blue.500}" }
}
}증상: 다크모드 추가 시 button.bg가 light용 색 그대로. 컴포넌트마다 mode 분기를 다시 추가해야 함.
대응: 반드시 semantic을 거치게. ESLint plugin으로 alias 패턴 검사.
함정 2: semantic 층 부재
// ❌ 나쁨 — semantic 층 없음
{
"button": { "bg": { "$value": "#3b82f6" } },
"card": { "bg": { "$value": "#ffffff" } },
"link": { "fg": { "$value": "#3b82f6" } }
}증상: “Primary”라는 공통 결정이 사라짐. button과 link의 색이 우연히 같은 hex일 뿐. 디자이너가 “primary 좀 더 푸르게”라고 해도 어디를 바꿔야 할지 모름.
대응: color.primary를 만들고 button·link 둘 다 거치게.
함정 3: alias의 alias의 alias (cycle)
{
"color": {
"a": { "$value": "{color.b}" },
"b": { "$value": "{color.c}" },
"c": { "$value": "{color.a}" }
}
}증상: Style Dictionary가 Reference cycle detected 에러 또는 무한 루프.
대응: alias 깊이 최대 2단계 권장 (primitive → semantic → component). 그 이상은 cycle 위험.
함정 4: 너무 많은 component 토큰
증상: button.primary.bg.default.light.hover.focus.disabled — 8단계 키 path. 아무도 찾지 못함.
원인: variant마다 component 토큰을 만들려는 욕심.
대응: variant는 recipe 층(04-recipes-variants)에서 표현. 토큰은 값의 슬롯만.
함정 5: semantic 이름이 너무 추상
{
"color": {
"alpha": { "$value": "{color.blue.500}" },
"beta": { "$value": "{color.green.500}" }
}
}증상: 디자이너도 개발자도 “alpha가 뭔지” 모름.
대응: semantic 이름은 역할을 드러나게. primary, success, danger, bg.surface, fg.muted 같은 기능적 이름.
Insight — 왜 “semantic”이 가장 중요한 층인가
3-tier에서 가장 자주 생략되거나 잘못 설계되는 것이 semantic 층이다. 그런데 모든 가치가 이 층에서 만들어진다.
- Primitive는 팔레트에 불과하다. 어떤 색 라이브러리(Tailwind, Radix Colors, OKLCH)를 골라도 큰 차이가 없다.
- Component는 위치 표시에 가깝다. 어떤 컴포넌트가 어떤 슬롯을 갖는지는 코드 구조가 결정한다.
- Semantic은 디자인의 의도가 박제되는 곳이다. “이 색은 위험을 뜻한다”, “이 배경은 부드러운 분위기를 만든다” — 그 추상화가 여기서 일어난다.
Radix Colors의 Adam Argyle은 이렇게 정리했다:
“Primitive without semantic is a palette. Semantic without primitive is a wish. Component without semantic is a duplication. Semantic is the contract.”
또 하나의 흥미로운 사실: Tailwind는 한참 동안 semantic 층을 거부했다. v3까지 Tailwind는 사실상 primitive flat token만 제공했다 (bg-blue-500). 사용자가 자신의 semantic 층을 따로 만들어야 했다. v4에 와서야 @theme와 함께 semantic alias를 1급 시민으로 받아들였다. 그 사이 Panda CSS는 처음부터 tokens (primitive) + semanticTokens (semantic alias)를 분리된 두 객체로 제공했다 — Panda의 디자인 시스템 친화성은 이 한 가지 결정에서 온다.
요약
- 토큰은 3-tier(primitive / semantic / component)로 쪼갠다.
- alias는 항상 위에서 아래로만 흐른다. 컴포넌트가 primitive를 직접 참조하면 안 된다.
- 다크모드는 semantic 층의 alias 대상만 재정의한다. primitive·component 정의는 그대로다.
- 멀티 브랜드도 같은 메커니즘. component 정의는 단 한 벌이고, brand마다 semantic alias만 다르다.
- semantic 층은 가장 자주 생략되지만 가장 가치 있는 층이다.