Design Tokens Without a Designer
디자이너가 토큰을 관리하지 않는 프로젝트에서는 개발자가 시안의 색과 간격을 화면마다 옮겨 적게 됩니다. 처음에는 빠르지만 같은 회색이 여러 hex 값으로 흩어지고 테마를 바꾸려는 순간 어떤 값을 고쳐야 하는지 알 수 없게 됩니다. 이 글은 개발자가 토큰을 시작할 때 계층과 이름을 정하는 기준, 토큰으로 만들지 않을 값, 그리고 선언 위치를 린트로 지키는 방법을 정리합니다.
문서로 공유한 규약은 코드에 남지 않았습니다
서버 템플릿 위에서 Sass로 화면을 만들던 프로젝트에서 RSCSS 네이밍 규약을 도입하고 팀 공통 스타일 가이드를 공유했습니다. RSCSS는 클래스를 두 단어 이상의 컴포넌트, 한 단어 요소, 대시로 시작하는 변형으로 나누고 요소는 직접 자식 선택자로 범위를 좁히도록 권합니다.
규약을 검사하는 도구는 두지 않았습니다. 나중에 코드를 다시 보니 직접 자식 선택자는 라이브러리 스타일을 덮는 몇 곳에만 있었습니다. 나머지는 페이지 이름을 앞에 붙인 클래스 아래로 하위 선택자를 중첩한 형태였습니다.
이후 운영 백오피스에서는 같은 종류의 규칙을 stylelint 설정으로 옮겼습니다. 화면(page), 영역(widget), 공용 UI(ui) 레이어마다 클래스 접두사와 요소, 수정자 형식을 정했습니다. 다른 레이어의 접두사를 겨냥하는 선택자, ui 레이어 밖에서 라이브러리 클래스를 직접 고치는 선택자, 지역 커스텀 프로퍼티 선언, !important, :not, aria 속성 선택자도 막았습니다. 규칙을 설정 파일로 옮긴 뒤로는 위반을 리뷰가 아닌 검사 단계에서 알게 됐습니다.
토큰에서도 같은 일이 생깁니다. 이름 규칙을 문서로만 두면 바쁜 화면 작업에서 hex 값이 다시 들어옵니다. 그래서 토큰은 계층과 이름, 선언 위치를 함께 정하고 선언 위치만큼은 도구로 강제하는 데서 시작합니다.
세 계층으로 나누고 한 계층만 참조합니다
토큰을 설명할 때 흔히 primitive, semantic, component 세 계층을 씁니다. W3C Design Tokens Community Group의 형식 문서(2025.10)는 이 계층을 정의하지 않습니다. 대신 그룹과 별칭({group.token}) 참조를 정의하고 이 참조로 semantic 토큰이 primitive 값을 가리키게 만들 수 있습니다.
| 계층 | 담는 것 | 이름 예 | 누가 참조하는가 |
|---|---|---|---|
| primitive | 팔레트, 크기 눈금 같은 원재료 값 | gray-600, space-3 | semantic 토큰만 |
| semantic | 화면에서의 쓰임 | color-text-muted, space-section | 화면과 컴포넌트 CSS |
| component | 특정 컴포넌트만의 결정 | button-radius | 그 컴포넌트 하나 |
디자이너가 없으면 primitive는 첫 시안에서 뽑은 팔레트로 충분합니다. 핵심은 화면 CSS가 primitive를 직접 쓰지 않게 하는 것입니다. gray-600을 화면에서 바로 쓰면 다크 테마에서 그 회색이 무엇으로 바뀌어야 하는지 이름만으로는 알 수 없습니다.
component 계층은 대부분 만들지 않습니다. 버튼의 모서리가 다른 입력 컨트롤과 일부러 달라야 하는 것처럼 semantic 이름으로 표현할 수 없는 결정이 생겼을 때만 추가합니다. 모든 컴포넌트에 미리 토큰을 만들어 두면 결정은 없이 이름만 두 배가 됩니다.
flowchart LR
P["primitive: gray-600"] --> S["semantic: color-text-muted"]
S --> U["화면과 컴포넌트 CSS"]
S -. 결정이 있을 때만 .-> C["component: button-radius"]
C --> U토큰을 JSON으로 관리한다면 형식 문서의 문법을 따르는 편이 나중에 도구를 붙이기 쉽습니다. 2025.10 형식에서 색은 colorSpace와 components를 가진 객체, 길이는 value와 unit(px 또는 rem)을 가진 객체입니다.
{
"base": {
"gray-600": {
"$type": "color",
"$value": { "colorSpace": "srgb", "components": [0.294, 0.333, 0.388], "hex": "#4b5563" }
},
"space-3": { "$type": "dimension", "$value": { "value": 12, "unit": "px" } }
},
"semantic": {
"color-text-muted": { "$type": "color", "$value": "{base.gray-600}" },
"space-section": { "$type": "dimension", "$value": "{base.space-3}" }
}
}이름은 값이 아니라 쓰임을 말합니다
--app-color-white라는 이름은 다크 테마가 들어오는 날 거짓말이 됩니다. 배경으로 쓰던 흰색이 어두운 회색으로 바뀌어도 이름은 여전히 white이기 때문입니다. 화면이 참조하는 토큰은 --<접두사>-<종류>-<쓰임> 형식으로 짓습니다.
- 접두사는 라이브러리가 넣는 변수와 섞이지 않게 앱 이름 하나로 고정합니다.
- 종류는
color,space,radius,shadow,z-index,font-size처럼 CSS 값의 범주입니다. - 쓰임은
surface,text-muted,section,control처럼 그 값을 고른 이유입니다.
쓰임을 말로 붙일 수 없는 값이라면 아직 공통 토큰이 아닐 가능성이 큽니다. 간격이 대표적입니다. 4px 배수 눈금은 primitive로 두고 화면에는 space-section, space-inline처럼 이유가 있는 이름만 내놓습니다.
글자 크기는 화면 폭에 따라 커지는 값을 토큰 하나에 담을 수 있습니다. 이때 MDN은 clamp()의 최댓값을 상대 단위로 쓰고 최솟값의 두 배 이상으로 두라고 안내합니다. 그래야 페이지를 확대했을 때 글자가 200%까지 커질 수 있습니다. 예전에 메모해 둔 제목 크기 clamp(2rem, 5vw, 3.5rem)은 최댓값이 최솟값의 두 배에 못 미쳐 이 기준을 통과하지 못합니다.
:root {
/* primitive. 이 파일 밖에서는 참조하지 않습니다 */
--app-gray-50: #f9fafb;
--app-gray-600: #4b5563;
--app-gray-900: #111827;
/* semantic. 화면과 컴포넌트는 이 이름만 씁니다 */
--app-color-surface: var(--app-gray-50);
--app-color-text: var(--app-gray-900);
--app-color-text-muted: var(--app-gray-600);
--app-space-section: 24px;
--app-font-size-heading: clamp(1.5rem, 1.2rem + 1.5vw, 3rem);
}
@media (prefers-color-scheme: dark) {
:root {
--app-color-surface: var(--app-gray-900);
--app-color-text: var(--app-gray-50);
}
}테마 분기는 이 파일에만 둡니다. 컴포넌트 CSS에 prefers-color-scheme이 들어가기 시작하면 색 하나를 더할 때 여러 파일을 열어야 합니다.
브레이크포인트는 CSS 커스텀 프로퍼티로 만들 수 없습니다. MDN 설명대로 var()는 속성 값 안에서만 쓸 수 있고 미디어 쿼리 조건에는 쓸 수 없습니다. 브레이크포인트는 Sass 변수나 빌드 시점 상수처럼 미디어 쿼리를 만드는 쪽에서 한곳에 모읍니다.
토큰으로 만들지 않는 값을 먼저 정합니다
토큰화를 시작하면 모든 숫자를 변수로 바꾸고 싶어집니다. 저는 값을 둘 자리를 프로젝트 기간이 아니라 파일 경계로 판단합니다.
flowchart TD
Q1{"여러 파일에서 쓰는가?"} -- 예 --> T["전역 토큰"]
Q1 -- 아니요 --> Q2{"테마 색, 그림자, z-index, 움직임 값인가?"}
Q2 -- 예 --> T
Q2 -- 아니요 --> Q3{"실행 중에만 계산되는가?"}
Q3 -- 예 --> L["지역 변수 하나를 스크립트에서 전달"]
Q3 -- 아니요 --> R["선언한 자리에 값 그대로"]| 값 | 처리 | 이유 |
|---|---|---|
| 여러 파일에서 쓰는 색, 간격 | 전역 토큰 | 한 번에 바꿀 대상이 분명합니다 |
| 한 파일 안에서만 반복되는 값 | 그대로 둡니다 | 변수로 감싸도 바꿀 곳은 한 파일입니다 |
| 테마에 따라 바뀌는 색과 그림자 | 한 번만 써도 토큰 | 테마 분기를 토큰 파일 하나에 모읍니다 |
z-index 층, 움직임 시간과 이징 | 한 번만 써도 토큰 | 앱 전체의 쌓임 순서와 움직임 리듬을 맞춥니다 |
| 측정값처럼 실행 중에만 아는 수치 | 지역 변수 하나 | 스크립트에서 넘길 다른 통로가 없습니다 |
지역 변수를 만들지 않는 이유도 있습니다. 컴포넌트 루트에 --card-gap을 선언하고 자식에서 var(--card-gap, 12px)로 쓰면 대체값 때문에 값은 사용처에 그대로 남고 참조만 늘어납니다. 바꿀 곳이 한 파일이라면 값을 그대로 두는 편이 읽기 쉽습니다.
선언 위치를 린트로 지킵니다
규칙은 두 가지입니다. 커스텀 프로퍼티는 토큰 파일에서만 선언하고 클래스는 자기 레이어의 접두사로만 짓습니다. 아래는 stylelint 17에서 확인한 규칙 이름으로 다시 쓴 예시이며 실제 프로젝트 설정을 옮긴 것은 아닙니다.
// 자기 접두사가 붙은 클래스만 형식 검사, 다른 클래스는 통과
const ownClass = (prefix) =>
`^(?:(?!${prefix}_).*|${prefix}_[a-z][a-zA-Z0-9]*__[a-z][a-zA-Z0-9]*(?:--[a-z][a-zA-Z0-9]*)?)$`
export default {
extends: ['stylelint-config-standard'],
rules: {
// 커스텀 프로퍼티 선언 금지. var() 사용은 걸리지 않습니다
'property-disallowed-list': ['/^--/'],
'declaration-no-important': true,
},
overrides: [
{
files: ['src/style/tokens.css'],
rules: {
'property-disallowed-list': null,
// var() 참조까지 검사하므로 토큰 파일에서만 켭니다
'custom-property-pattern': '^app-[a-z0-9-]+$',
},
},
{
files: ['src/feature/**/*.css'],
rules: {
'selector-class-pattern': ownClass('feat'),
'selector-disallowed-list': [[/\.(page|ui)_/, /\.Mui/], { splitList: true }],
},
},
],
}custom-property-pattern은 선언뿐 아니라 var() 안의 이름도 검사합니다. 라이브러리 변수를 읽는 파일에서 켜면 남의 이름까지 걸리므로 토큰 파일에만 둡니다. 화면 CSS의 hex 값까지 막고 싶다면 color-no-hex를 토큰 파일 밖에 켤 수 있습니다. 다만 시안에서 토큰으로 옮기지 않은 값이 많은 초기에는 경고가 너무 많아 규칙 자체를 끄게 되기 쉽습니다.
린트가 확인하지 못하는 것도 분명합니다. color-danger를 위험하지 않은 강조에 쓰는지, 한 파일의 값을 전역 토큰으로 올릴 때가 됐는지, 색 대비가 충분한지는 리뷰에서 봅니다. 대신 진행 상황은 숫자로 봅니다. 토큰 파일 밖의 hex 값 수와 선언만 하고 아무도 쓰지 않는 토큰을 주기적으로 세면 토큰이 실제로 쓰이는지 드러납니다.
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
const root = 'src'
const tokenFile = join(root, 'style/tokens.css')
const files = readdirSync(root, { recursive: true, encoding: 'utf8' })
.filter((file) => file.endsWith('.css'))
.map((file) => join(root, file))
const declared = new Set(readFileSync(tokenFile, 'utf8').match(/--app-[a-z0-9-]+(?=\s*:)/g))
const used = new Set<string>()
let rawHex = 0
for (const file of files) {
const css = readFileSync(file, 'utf8')
for (const ref of css.match(/var\(--app-[a-z0-9-]+/g) ?? []) used.add(ref.slice(4))
if (file !== tokenFile) rawHex += css.match(/#[0-9a-f]{3,8}\b/gi)?.length ?? 0
}
console.log({ rawHex, declared: declared.size, unused: [...declared].filter((name) => !used.has(name)) })정규식으로 센 값이라 주석 안의 색이나 대체값도 함께 셉니다. 정확한 수치보다 추세를 보는 용도로 씁니다.
디자이너가 합류할 때 넘길 형식
디자이너가 합류하거나 디자인 도구와 토큰을 맞춰야 하면 CSS 파일보다 도구가 읽을 수 있는 형식이 필요합니다. 이때 후보가 앞에서 본 DTCG 형식입니다. 2025.10 버전은 2025년 10월 28일에 공개됐고 문서 스스로 W3C 표준이나 표준 트랙 문서가 아니라고 밝힙니다.
변환 도구의 지원 범위도 확인해야 합니다. Style Dictionary 문서는 버전 4부터 DTCG 형식을 지원한다고 적으면서 2025.10 형식은 아직 완전히 지원하지 않는다고 밝히고 있습니다. 이 글을 쓰는 시점의 최신 버전은 5.7.1입니다. 지금 JSON을 정본으로 바꾸면 색 객체 같은 새 문법이 변환 단계에서 막힐 수 있습니다.
그래서 디자이너가 없을 때는 CSS 토큰 파일 하나를 정본으로 두고 이름을 형식 문서의 그룹 구조와 맞춰 둡니다. 도구가 읽어야 하는 시점이 오면 그때 JSON으로 옮깁니다. 이름이 쓰임으로 지어져 있다면 옮기는 일은 변환에 가깝고 다시 설계할 일은 줄어듭니다. 이 글은 CSS 토큰 파일과 린트까지를 다뤘고 DTCG 변환 파이프라인은 다루지 않았습니다.