🧩 Design System1. Tokens (DTCG·계층)Token Tiers — primitive / semantic / component 3-tier 계층

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 1Primitive (= “core” / “global” / “reference”)의미 없는 raw 값. 색 팔레트, 스케일디자인 시스템 팀매우 낮음 (분기/연 단위)
Tier 2Semantic (= “alias” / “system”)역할에 붙인 이름. 테마에 따라 다른 primitive를 가리킴디자인 시스템 팀 + 프로덕트 디자이너낮음 (월 단위)
Tier 3Component (= “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-defaultvar(--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 층은 가장 자주 생략되지만 가장 가치 있는 층이다.