디자인 토큰 3계층: primitive, semantic, component

버튼 하나에 파란 배경을 넣는다. CSS를 열고 background: #1E46A0이라고 적는다. 화면에서는 완벽하게 잘 나온다. 문제될 게 없어 보인다. 몇 달 뒤 다크모드를 붙여달라는 요청이 들어오고, 그때 이 한 줄이 어디에 몇 개나 흩어져 있는지 세기 시작하면서 일이 커진다.
#1E46A0은 라이트 배경 위에서는 대비가 충분하지만, 어두운 배경 위에 그대로 얹으면 배경에 잠겨 잘 안 보인다. 다크모드에서는 더 밝은 파랑으로 바꿔야 한다. 그런데 그 파랑을 하드코딩으로 박아둔 자리가 버튼에만 있는 게 아니다. 링크에도, 탭 인디케이터에도, 포커스 링에도, 로딩 스피너에도 같은 값이 복제돼 있다. 다크모드 하나 붙이겠다고 프로젝트 전체를 훑으며 #1E46A0을 찾아 고쳐야 하는 상황. 이 부채의 출발점은 딱 하나다. 값을 값 그대로, 그것도 컴포넌트가 직접 붙든 것이다.
디자인 토큰을 계층으로 나누는 이야기는 여기서 시작한다. 색 하나를 어디에 어떻게 적어두느냐가, 나중에 테마를 갈아끼울 수 있는지 없는지를 결정한다.
세 계층: 값, 의도, 소비자
토큰을 계층으로 나눈다는 건 색이나 간격 같은 값을 한 번에 쓰지 않고, 성격이 다른 세 단계를 거쳐 쓴다는 뜻이다. 계층마다 답하는 질문이 다르다.
본격적으로 들어가기 전에 비유를 하나 깔고 가자. 스마트폰 전화번호부를 떠올리면 이 글 전체가 훨씬 쉬워진다. 번호부에는 세 가지가 함께 있다. 하나는 실제 전화번호다. 010으로 시작하는 열한 자리 숫자 그 자체. 다른 하나는 그 번호에 붙인 이름표다. 엄마, 회사, 단골 미용실처럼. 마지막은 그 이름표를 눌러 전화를 거는 나다. 나는 열한 자리를 외우지 않고 그냥 엄마를 누른다. 디자인 토큰의 세 계층이 정확히 이 셋과 겹친다. 실제 번호가 원시, 이름표가 의미, 이름표를 누르는 쪽이 컴포넌트다. 아래에서 계층이 나올 때마다 이 번호부를 다시 꺼내겠다.
1계층은 원시 토큰(primitive token)이다. 순수한 팔레트 값이다. 이름이 곧 값 그 자체다. blue-500이 #1E46A0을 가리키고, space-4가 16px를 가리킨다. 여기엔 의도가 없다. blue-500은 그냥 500번째 밝기의 파랑일 뿐, 이게 액션 색인지 위험 색인지는 모른다. 팔레트 사전이라고 보면 된다. 번호부로 치면 저장된 실제 번호 그 자체다. 번호에는 이게 누구 번호인지, 어디에 쓸 번호인지가 적혀 있지 않다. blue-500이라는 칸 이름조차 그저 몇 번째 밝기의 파랑인지를 말할 뿐이다.
2계층은 의미 토큰(semantic token)이다. 별칭 토큰(alias token)이라고도 부른다. 값을 직접 갖지 않고, 원시 토큰을 가리키면서 의도를 붙인다. color-action이 blue-500을 가리키고, color-danger가 red-500을 가리킨다. 여기서 처음으로 파랑이 액션이라는 뜻을 얻는다. 번호부로 치면 #1E46A0이라는 번호에 이건 액션에 쓸 색이라고 이름표를 달아두는 단계다. 우리가 열한 자리 번호에 엄마라는 이름표를 붙이는 그 동작이다. 이름표가 번호의 생김새(파랑)가 아니라 역할(액션)을 말한다는 게 핵심이다.
3계층은 컴포넌트 토큰(component token)이다. 특정 컴포넌트 안에서만 쓰이는 값이다. button-bg가 color-action을 가리키고, card-padding이 space-4를 가리킨다. 버튼이라는 부품이 자기 배경색으로 무엇을 쓸지 결정하는 자리다. 번호부로 치면 버튼이라는 앱이 엄마 이름표를 눌러 전화를 거는 자리다. 버튼은 그 뒤에 실제로 어떤 번호가 걸리는지 모른 채 이름표만 부른다.
규칙은 단순하다. 각 계층은 바로 아래 계층만 참조한다. 컴포넌트 토큰은 의미 토큰만 본다. 의미 토큰은 원시 토큰만 본다. 원시 토큰은 아무것도 참조하지 않고 순수한 값을 들고 있다. 컴포넌트가 원시 토큰을 건너뛰고 직접 잡거나, 의미 토큰이 다른 의미 토큰을 참조하기 시작하면 계층이 흐트러진 것이다. 이 참조 방향 하나가 이 글의 거의 전부라고 해도 된다.
왜 굳이 세 개인가
계층을 두 개도 아니고 세 개나 쌓는 이유는 하나다. 테마를 갈아끼울 때 손대는 자리를 한 곳으로 몰기 위해서다.
다크모드를 생각해보자. 라이트 테마에서 액션 색은 진한 파랑이어야 하고, 다크 테마에서는 밝은 파랑이어야 한다. 계층이 제대로 서 있으면, 이 재매핑은 의미 계층 한 곳에서만 일어난다. color-action이 라이트에서는 진한 파랑을, 다크에서는 밝은 파랑을 가리키게 바꾼다. 그게 전부다. 번호부로 치면 엄마가 폰을 바꿔 번호가 달라진 상황이다. 엄마 이름표가 가리키는 번호만 새 번호로 고치면 끝이고, 엄마에게 걸던 사람들은 아무것도 바꿀 필요가 없다.
버튼은 어떻게 될까. 버튼은 color-action만 보고 있으니, 자기가 무슨 짓을 할 필요가 없다. 링크도, 포커스 링도, 스피너도 전부 color-action을 보고 있으니 함께 따라온다. 재매핑 지점 하나를 건드렸는데 그 아래에 매달린 수십 개 컴포넌트가 한꺼번에 갈아탄다. 이게 계층을 나눠 얻는 실익이다.
멀티 브랜드도 같은 구조로 풀린다. 화이트라벨 제품에서 브랜드마다 액션 색이 파랑이었다 초록이었다 하는 상황을 생각해보자. 브랜드 A의 팔레트, 브랜드 B의 팔레트를 원시 계층에 각각 깔아두고, 의미 계층에서 color-action이 어느 팔레트를 가리킬지만 스위치한다. 컴포넌트는 자기가 A 브랜드에서 도는지 B 브랜드에서 도는지 영영 모른 채로 그냥 color-action을 쓴다.
반대로, 컴포넌트가 원시 토큰을 직접 쓰면 이 실익이 통째로 날아간다. 버튼이 blue-500을 직접 잡고 있으면, 테마마다 버튼을 따로 뜯어고쳐야 한다. 링크도, 스피너도 마찬가지다. 번호부로 치면 이름표를 놔두고 버튼이 엄마 번호 열한 자리를 통째로 외워버린 격이라, 엄마가 번호를 바꾸면 그 번호를 외운 곳을 하나하나 다시 찾아 고쳐야 한다. 재매핑 지점이 하나가 아니라 컴포넌트 수만큼 흩어진다. 애초에 다크모드 요청에 프로젝트 전체를 훑게 된 이유가 정확히 이것이다. 중간에 의도를 갈아끼울 관절이 없었던 거다.
여기서 한 가지 짚어둘 게 있다. 지금까지의 이득은 사실 원시와 의미, 두 계층만으로도 나온다. 값과 의도를 갈라놓은 것만으로 재매핑 관절이 생겼다. 그럼 세 번째 계층인 컴포넌트 토큰은 왜 두는가. 그 답은 결이 조금 달라서 이 글 뒤에서 따로 본다. 우선은 세 계층이 코드에서 어떻게 맞물리는지부터 보자.
코드로 보면
CSS 커스텀 프로퍼티로 세 계층을 그리면 참조 방향이 눈에 보인다.
여기서부터 코드가 나온다. 미리 말해두면, 디자이너가 이 코드를 직접 짤 일은 없다. 모양만 눈으로 읽으면 된다. 그 모양은 색이 어디서 나와 어디로 흘러가는지를 그린 지도이고, 그건 디자이너가 시안에서 색을 정할 때 신경 쓰는 것과 정확히 같은 것이기 때문이다.
읽기 전에 기호 두 개만 익히면 된다. CSS에서 이름 앞에 붙은 짧은 줄 두 개(--)는 값을 담아둘 칸을 새로 만든다는 표시다. --blue-500: #1E46A0은 번호부에 blue-500이라는 칸을 만들고 거기에 #1E46A0을 적어두라는 뜻이다. 번호를 이름과 함께 저장하는 것과 같다. 그리고 var(...)는 그 칸에 적어둔 값을 도로 꺼내 읽으라는 표시다. var(--blue-500)은 blue-500 칸에 저장해둔 번호를 다시 불러오라는 뜻이다. 저장된 이름을 눌러 전화를 거는 것과 같다. --로 저장하고 var(...)로 부른다. 이 둘만 알면 아래 코드는 전부 읽힌다.
/* 1계층 primitive: 이름이 곧 값이다. 아무것도 참조하지 않는다 */
:root {
--blue-500: #1E46A0;
--blue-400: #5B8DEF;
--red-500: #DC2828;
--space-4: 16px;
}
/* 2계층 semantic: 값을 직접 갖지 않고 원시 토큰을 가리키며 의도를 붙인다 */
:root {
--color-action: var(--blue-500);
--color-danger: var(--red-500);
}
/* 재매핑은 오직 이 의미 계층에서만 일어난다 */
[data-theme="dark"] {
--color-action: var(--blue-400);
}
/* 3계층 component: semantic만 본다. primitive도 테마도 모른다 */
:root {
--button-bg: var(--color-action);
--button-padding: var(--space-4);
}
위에서 아래로 훑어보자. 맨 위 1계층 블록은 번호부에 진짜 번호들을 저장하는 자리다. --blue-500에 #1E46A0, --red-500에 #DC2828을 적어둔다. 이름표도 역할도 없이 번호만 쌓아둔 주소록이다. 가운데 2계층 블록은 그 번호에 이름표를 붙인다. --color-action: var(--blue-500)은 액션이라는 이름표를 만들어 blue-500 칸의 번호를 가리키게 한다는 뜻이다. 특정 번호에 엄마라는 이름을 연결해 저장하는 그 동작이다. 바로 아래 [data-theme="dark"] 블록은 다크모드일 때만 그 액션 이름표가 blue-400 칸을 가리키도록 다시 단다. 엄마가 번호를 바꿨을 때 이름표만 새 번호로 고치는 자리이고, 재매핑은 오직 여기 한 곳에서만 일어난다. 맨 아래 3계층 블록은 버튼이 어느 이름표를 부를지 정한다. --button-bg: var(--color-action)은 버튼 배경이 액션 이름표를 부른다는 뜻이다.
.button {
background: var(--button-bg);
padding: var(--button-padding);
}
.button을 보면 파랑이라는 단어도, #1E46A0이라는 값도, 다크모드라는 개념도 없다. 버튼은 --button-bg 하나만 안다. 번호부로 치면 버튼은 엄마 이름표만 누를 뿐, 그 뒤에 실제로 어떤 번호가 걸리는지는 영영 모른다. 그 위로 올라가면 --button-bg는 --color-action을, --color-action은 테마에 따라 --blue-500 또는 --blue-400을 가리킨다. 값이 바뀌는 층과, 의도가 정해지는 층과, 그 의도를 소비하는 층이 깔끔하게 분리돼 있다. 다크모드 전환에 손대는 곳은 [data-theme="dark"] 블록 한 군데뿐이다.
계층이 무너질 때 쌓이는 부채
계층 구조는 지켜질 때보다 무너질 때 더 잘 보인다. 무너지는 방식은 몇 가지로 정형화돼 있다.
첫째, 컴포넌트가 원시 토큰을 직접 참조한다. 앞에서 본 그 문제다. 두 가지 형태가 있는데, 하나는 아예 토큰도 아닌 생값을 박는 경우고, 다른 하나는 토큰이긴 한데 계층을 건너뛴 경우다.
/* 안티패턴 1: 아예 토큰이 아닌 생값 */
.button { background: #1E46A0; }
/* 안티패턴 2: 토큰이긴 하지만 원시 계층을 직접 잡아 계층을 건너뛴다 */
.button { background: var(--blue-500); }
두 줄의 차이만 짚으면 된다. 위 줄은 번호부를 아예 안 쓰고 열한 자리 번호를 손으로 눌러 건 것이고, 아래 줄은 번호부를 쓰긴 했는데 엄마 이름표가 아니라 blue-500이라는 번호칸을 직접 누른 것이다. 그런데 두 번째가 더 위험하다. 토큰을 쓰고 있으니 겉보기엔 규칙을 지킨 것 같은데, 실제로는 의미 계층을 건너뛰어서 다크모드에서 재매핑이 먹지 않는다. --blue-500은 이름표가 아니라 고정된 번호칸이라, 테마가 바뀌어도 그대로다. 버튼만 다크에서 어두운 파랑으로 남아 배경에 잠긴다. 토큰을 썼다는 안도감이 오히려 문제를 늦게 발견하게 만든다.
둘째, 의미 토큰의 이름을 외형으로 짓는다. 조용히 퍼지는 종류다.
/* 안티패턴: 역할이 아니라 외형으로 이름 붙인 의미 토큰 */
--color-blue: var(--blue-500);
/* 이렇게 지어야 이름이 나중에 거짓말을 하지 않는다 */
--color-action: var(--blue-500);
--color-blue는 의미 계층에 앉아 있지만 하는 일이 없다. 파랑을 파랑이라 부를 뿐이라, 원시 토큰을 한 겹 감싼 것 말고는 얻는 게 없다. 번호부로 치면 엄마를 엄마라 저장하지 않고 그 사람 폰이 파란색이라고 파란폰이라 저장한 격이다. 문제는 브랜드가 액션 색을 초록으로 바꾸기로 결정한 순간 터진다. --color-blue가 이제 초록을 가리키게 된다. 이름은 파랑인데 값은 초록인, 스스로 거짓말하는 토큰이 된다. 엄마가 검은 폰으로 바꾼 뒤에도 이름표에 파란폰이라 적혀 있는 꼴이다. 그 이름을 참조한 모든 컴포넌트 코드가 파란 줄 알고 짠 자리에서 초록이 튀어나온다. 의미 토큰의 이름은 무엇으로 보이는가가 아니라 무엇을 위한 것인가로 지어야 한다. 색을 빨강으로 바꿔도 이름이 여전히 맞아야 제대로 된 의미 토큰이다.
셋째, 의미 토큰이 옆으로 참조하거나 순환에 빠진다. 첫째가 계층을 아래로 건너뛰는 문제였다면, 이건 같은 계층끼리 옆으로 새는 문제다. 참조는 아래로 한 칸씩이어야 하는데, 의미 토큰이 다른 의미 토큰을 가리키기 시작하면 계층이 옆으로 번진다. color-danger가 color-action을 가리키는 식으로 의미끼리 얽히면, 어느 날 액션 색을 바꿨는데 위험 색까지 따라 바뀌는 황당한 연쇄가 생긴다. 심하면 A가 B를, B가 A를 참조하는 순환이 만들어져 빌드 도구가 값을 해석하지 못하고 멈춘다. 참조는 언제나 위에서 아래로 한 방향, 그리고 한 칸씩만. 이 규칙을 어기는 순간 계층은 이름만 남고 실체는 없어진다.
네이밍이 곧 API 설계다
여기까지 오면 토큰 이름 짓기가 취향 문제가 아니라는 게 분명해진다. 의미 토큰의 이름은 이 디자인 시스템을 쓰는 사람에게 노출되는 공개 인터페이스다. 컴포넌트를 짜는 개발자는 --color-action이라는 이름 하나만 보고 그 뒤에 어떤 파랑이 있는지, 다크모드에서 어떻게 바뀌는지 몰라도 되게 만드는 게 목표다. 그러니 이름은 내부 구현(파랑)이 아니라 계약(액션에 쓰는 색)을 드러내야 한다. 함수 이름을 add라 짓지 plusInt32라 짓지 않는 것과 같은 감각이다.
이 블로그의 색 토큰도 같은 원리로 서 있다. 원시 계층에 팔레트가 깔려 있다.
// 원시 계층: 팔레트 값 그 자체
--accent-blue: #1E46A0;
--accent-red: #DC2828;
--gray-1000: #0A0A0A;
// 의미 계층: 역할로 이름 붙이고 원시를 가리킨다
--color-fg: var(--gray-1000);
--color-link: var(--accent-blue);
--color-error: var(--accent-red);
이것도 앞의 번호부 그대로다. 위 세 줄이 실제 번호를 저장하는 원시 계층이고, 아래 세 줄이 그 번호에 역할 이름표를 붙인 의미 계층이다. --color-fg(전경, 본문 글자색), --color-link(링크색), --color-error(에러색)가 의미 계층이고, 각각 --gray-1000, --accent-blue, --accent-red 같은 원시 토큰을 가리킨다. 이름을 보면 이게 무슨 색인지가 아니라 어디에 쓰는 색인지가 읽힌다. 그게 의미 계층이 제 일을 하고 있다는 신호다.
다만 정직하게 밝혀둘 게 있다. 이 블로그의 토큰은 사실 원시와 의미 두 계층까지만 있고, 컴포넌트 계층은 따로 두지 않았다. 그리고 다크모드 재매핑 방식도 앞에서 설명한 정석과 조금 다르다. 정석은 --color-link가 라이트에서는 진한 파랑 원시를, 다크에서는 밝은 파랑 원시를 가리키도록 참조 자체를 바꾼다. 그런데 이 블로그는 --color-link가 늘 --accent-blue를 가리키게 두고, 다크 테마에서 --accent-blue라는 원시 토큰의 값 자체를 밝은 파랑으로 다시 정의한다. 번호부로 옮기면, 정석은 엄마 이름표가 다크모드에서 다른 번호칸을 가리키도록 이름표를 다시 붙이는 방식이고, 이 블로그는 이름표는 그대로 두되 그 이름표가 늘 가리키는 번호칸에 적힌 번호 자체를 다크모드에서 새 번호로 고쳐 쓰는 방식이다. 둘 다 결국 엄마에게 걸면 새 번호로 연결된다.
// 이 블로그가 고른 방식: 참조는 고정, 원시 값을 테마별로 재정의
[data-theme="dark"] {
--accent-blue: #5B8DEF; // 원시 토큰의 값을 바꾼다
--gray-1000: #F0F0F0; // --color-fg는 그대로 이걸 가리킨다
}
이 블록이 방금 말한 재정의 방식이다. [data-theme="dark"] 안에서 --accent-blue라는 번호칸에 적힌 값을 아예 #5B8DEF로 덮어쓴다. --color-link는 여전히 --accent-blue를 가리키고 있으니, 이름표는 손도 대지 않았는데 그 칸의 번호가 바뀌어서 링크색이 따라 밝아진다. 재매핑 지점이 의미 계층이냐 원시 계층이냐가 다를 뿐, 재매핑이 한 곳에서만 일어난다는 원칙은 지켜진다. 두 방식 다 유효하다. 다만 원시 값을 테마마다 재정의하는 쪽은 --accent-blue라는 이름이 라이트에선 진한 파랑, 다크에선 밝은 파랑이라는 서로 다른 값을 뜻하게 돼서, 원시 토큰은 값 그 자체라는 1계층의 순수함이 살짝 흐려진다. 규모가 커지면 이 흐려짐이 비용으로 돌아오기도 한다. 그럼에도 개인 블로그 규모에서는 이 편이 단순하고 충분하다. 이렇게 같은 원칙도 실제로 구현할 때는 시스템마다 손이 갈린다. 계층에 붙이는 이름부터가 그렇다.
표준은 어디까지 왔나
계층 이름은 시스템마다 조금씩 다르다. 이 글은 원시·의미·컴포넌트라고 불렀지만, 어도비 스펙트럼(Adobe Spectrum)과 세일즈포스는 global, alias, component라 부르고, 머티리얼 디자인 3(Material Design 3)은 reference, system, component로 나눠 md.ref, md.sys, md.comp 접두어를 쓴다. 이름은 달라도 층의 성격은 같다. 값을 들고 있는 층, 의도를 붙이는 층, 컴포넌트가 소비하는 층. 그러니 3계층이라는 구조 자체는 특정 회사의 발명이라기보다 여러 시스템이 각자 도달한 공통 패턴에 가깝다.
토큰을 도구 사이에서 주고받는 파일 포맷도 표준화가 진행됐다. 디자인 토큰 커뮤니티 그룹(Design Tokens Community Group)이 만드는 포맷 명세가 2025년 10월 28일에 첫 안정 버전(2025.10)에 도달했다. JSON을 교환 형식으로 쓰고, 토큰마다 $value, $type, $description 같은 속성으로 값과 타입과 설명을 적는다. 별칭과 참조 관계도 명세에 들어가 있어서, 앞에서 이야기한 계층 참조를 도구 중립적인 파일로 표현할 수 있다. 다만 이 명세는 W3C의 정식 표준(W3C Standard)이 아니라 커뮤니티 그룹의 산출물이라는 점은 짚어둔다. 그래도 피그마, 스케치, 프레이머 같은 도구들이 지원에 들어가면서, 토큰을 디자인 도구와 코드 사이에서 한 벌로 굴리는 흐름은 점점 현실이 되고 있다.
세 계층이 과할 때
여기까지 읽고 나면 무조건 세 계층을 깔아야 할 것 같지만, 그렇지 않다. 계층은 공짜가 아니다. 층이 하나 늘 때마다 이름을 짓고, 참조를 잇고, 문서로 설명하고, 팀에 왜 이렇게 쓰는지 납득시키는 비용이 붙는다. 버튼 배경색을 확인하려고 세 번 점프해서 원시 값까지 따라가야 하는 간접 참조의 피로도 실재한다.
컴포넌트 계층이 특히 그렇다. 컴포넌트 토큰이 값을 붙이지 않고 의미 토큰을 그대로 통과시키기만 한다면, 즉 --button-bg: var(--color-action) 말고 하는 일이 없다면, 그 계층은 지금 당장은 순수한 오버헤드다. 컴포넌트 계층이 밥값을 하는 건 버튼만의 예외가 생길 때다. 다른 액션 요소는 다 color-action을 쓰는데 버튼만 조금 다른 파랑을 써야 한다거나, 버튼 배경만 다크모드에서 별도로 조정해야 할 때. 그런 국소 예외를 의미 계층까지 올리지 않고 컴포넌트 계층에 가둬두는 게 3계층의 진짜 쓸모다. 예외가 없으면 그 방어벽도 필요 없다.
그래서 규모가 판단 기준이 된다. 화면 몇 개짜리 소규모 프로젝트, 테마가 하나뿐이고 브랜드도 하나인 제품이라면, 원시와 의미 두 계층으로 충분한 경우가 많다. 이 블로그가 그 예다. 다크모드까지는 두 계층으로 감당된다. 세 번째 계층은 다음 신호가 보일 때 늘리면 된다. 컴포넌트별 예외가 의미 계층을 오염시키기 시작할 때, 테마가 둘을 넘어 셋 넷으로 불어날 때, 여러 팀이 같은 토큰을 공유하면서 서로의 변경이 남의 컴포넌트를 깨뜨릴 때. 그 전까지 세 번째 계층은 대비가 아니라 부채다.
결국 계층은 목적이 아니라 도구다. 색을 blue-500으로 직접 쓰지 말자는 규칙은, 나중에 그 색을 한 곳에서 갈아끼울 관절을 미리 심어두자는 뜻이다. 관절이 몇 개 필요한지는 갈아끼울 일이 얼마나 자주 생기느냐가 정한다. 다크모드 하나 붙이겠다고 프로젝트 전체를 훑던 그 아침으로 돌아가지 않으려면, 값과 의도와 소비를 분리하는 감각 하나면 충분하다. 계층의 개수는 그다음 문제다.
이 글, 어떠셨어요?