lib/ 안의 파이프라인이다 — 토큰 파일을 읽어들이는 파서(parser)부터, 값을 다듬는 전처리기(preprocessor), 플랫폼별로 값/이름/속성을 바꾸는 변환기(transform), 마지막으로 파일을 찍어내는 포맷터(format)까지. 그리고 2022년의 v4 대개편(ESM 전환·비동기 API·브라우저 지원·W3C 디자인 토큰 표준 대응)이 코드에 정확히 어떤 흔적을 남겼는지까지 확인한다.
style-dictionary/style-dictionary · 라이선스 Apache-2.0 · 최신 버전 5.5.0 · Node.js 22.0.0 이상 필요 · 최신 커밋 9a9cca04(2026-06-21) · 런타임 의존성 13개 · 내장 transform 62개 · 내장 format 44개 · 내장 transformGroup 14개 · 테스트 파일 97개)
Style Dictionary를 한 문장으로 말하면 "디자인 토큰을 입력받아, 등록된 변환·포맷 규칙에 따라 여러 플랫폼용 코드 파일을 출력하는 Node.js 빌드 도구"다. 여기서 디자인 토큰이란 "브랜드의 주요 색은 #3452eb다", "기본 여백 단위는 8px이다" 같은, 디자인의 최소 결정 단위를 이름 붙여 저장한 값을 말한다. 이 값들을 JSON(또는 YAML) 파일에 모아두면, Style Dictionary가 그것을 읽어 iOS 앱이 쓰는 .swift 파일, 안드로이드 앱이 쓰는 colors.xml, 웹사이트가 쓰는 CSS 커스텀 프로퍼티(--color-brand) 등으로 동시에 바꿔준다.
npm 패키지명도 저장소명도 style-dictionary이며, CLI 명령어 style-dictionary build로도 쓰고, Node.js 모듈로 import해서 스크립트 안에서 직접 제어할 수도 있다. 코드 자체를 그리는 디자인 툴이 아니라, "이미 정해진 디자인 값을 각 플랫폼의 코드로 번역해 옮겨 적는" 순수한 빌드 계층이라는 점이 정체성의 핵심이다.
color.brand.primary = "#3452eb". 코드에 값을 직접 박아넣는(hard-code) 대신 이름으로 참조하게 해, 값이 바뀌어도 이름 하나만 고치면 모든 곳에 반영되게 한다.디자인 토큰은 모든 번역의 원본이 되는 단어장이다. "brand-blue"라는 표제어 하나에 뜻(#3452eb)이 하나 달려 있다.
Style Dictionary는 이 단어장을 들고 iOS 통역사, 안드로이드 통역사, 웹 통역사에게 동시에 넘겨준다. 통역사마다 같은 표제어를 자기 나라 말투(Swift 상수, XML 리소스, CSS 변수)로 옮겨 적을 뿐, 원본 뜻은 절대 바뀌지 않는다. 단어장을 고치면 세 통역사의 번역문이 전부 자동으로 갱신된다.
스타트업이든 대기업이든 앱을 iOS·Android·웹으로 동시에 내는 게 기본값이 된 지 오래다. 그런데 각 플랫폼 개발자가 "브랜드 파란색"을 각자 따로 하드코딩하면, 리브랜딩 한 번에 세 팀이 따로 값을 찾아 바꿔야 한다. Style Dictionary는 "값의 원본은 하나"라는 원칙을 강제해 이 반복 작업과 불일치를 구조적으로 없앤다. 그래서 디자인 시스템을 운영하는 조직 대부분이 이 도구이거나 이 도구의 개념을 그대로 흉내 낸 사내 도구를 쓴다.
디자인 토큰 생태계는 오랫동안 "회사마다 JSON 형식이 제각각"이라는 문제를 겪었다. W3C 커뮤니티 그룹이 만드는 DTCG(Design Tokens Community Group) 표준($value/$type 문법)이 자리를 잡으면서, Figma의 Tokens Studio 플러그인 같은 토큰 저작 도구와 Style Dictionary 같은 토큰 빌드 도구가 같은 포맷으로 대화할 수 있게 됐다. Style Dictionary는 이 표준을 소스 레벨에서 실제로 계속 뒤쫓아 구현하고 있다(4절에서 코드로 확인).
AI 코딩 도구가 UI 코드를 빠르게 뽑아낼수록, 정작 "이 색이 진짜 맞는 색인가"를 검증할 단일 진실 공급원(single source of truth)의 가치가 커진다. Style Dictionary로 빌드된 산출물은 사람이든 AI든 "이 변수를 쓰면 틀릴 수 없다"는 확실성을 주기 때문에, 최근 디자인 시스템/토큰 관련 오픈소스가 다시 활발해진 흐름과 함께 재조명받고 있다.
Theo(옛 세일즈포스 도구)가 사실상 뒤로 물러난 자리를 이어받아, 가장 오래 실전에서 검증된 토큰 빌드 도구로 자리잡았다. 이후 등장한 Terrazzo/Cobalt 같은 신생 도구들이 "더 단순한 플러그인 구조"를 내세우며 도전하고 있지만(10절), Style Dictionary는 지원 플랫폼의 폭과 커스텀 확장성에서 여전히 기본 선택지로 꼽힌다.
Style Dictionary가 없는 세상을 먼저 상상해보자. 디자이너가 Figma에서 브랜드 파란색을 #3452EB로 정했다고 치자. 이 값을 실제 제품에 반영하려면:
UIColor(red: 0.204, green: 0.322, blue: 0.922, alpha: 1)처럼 0~1 사이 부동소수점으로 손수 변환해 .swift 파일에 옮겨 적는다.<color name="brand_primary">#3452EB</color>를 colors.xml에 따로 넣는다.--color-brand-primary: #3452eb;를 또 따로 넣는다.세 곳 모두 같은 값을 세 번, 서로 다른 문법으로 손으로 옮겨 적은 것이다. 브랜드 색이 바뀌면 세 곳을 다 찾아 고쳐야 하고, 그중 하나라도 놓치면 "웹은 새 파랑인데 앱은 옛날 파랑"인 불일치가 생긴다. 이게 실제로 디자인 시스템 팀들이 매일 겪던 문제였다.
Style Dictionary는 이 흐름을 뒤집는다. 값은 토큰 JSON에 딱 한 번만 적고, "iOS는 0~1 부동소수점으로, Android는 #AARRGGBB 8자리 헥사로, 웹은 #rrggbb 그대로"라는 변환 규칙을 도구에게 등록해두면, 빌드 명령 한 번에 세 파일이 동시에, 항상 같은 원본에서 정확히 생성된다. 사람이 값을 옮겨 적으며 실수할 자리가 원천적으로 사라진다.
환율 계산을 손으로 하지 않는 이유와 같다. 원화 1000원이 달러로 얼마인지 나라마다 사람이 암산해서 장부에 적으면 언젠가 계산 실수가 난다. 대신 "오늘 환율표(원본)"를 딱 하나 두고, 각 통화별 계산 공식(변환 규칙)을 프로그램에 맡기면 장부는 항상 정확하고 일관된다. 토큰 원본이 환율표, 플랫폼별 변환 규칙이 환전 공식, 산출 파일들이 각국 화폐로 찍힌 최종 장부다.
Style Dictionary의 몸통은 lib/StyleDictionary.js(984줄) 하나의 클래스다. 이 클래스가 Register(lib/Register.js, 후크 등록 담당)를 상속받고, 나머지 기능은 lib/utils/*·lib/transform/*·lib/common/*에 흩어진 순수 함수들을 불러 쓰는 구조다. 전체 흐름을 독수리 시점으로 그리면 이렇다.
주목할 점은 3)~6)이 "플랫폼마다 통째로 다시 실행"된다는 것이다. 같은 토큰이라도 iOS 플랫폼에서는 ios transformGroup으로, 웹 플랫폼에서는 css transformGroup으로 완전히 다른 값·이름이 나온다. 원본 토큰 트리(this.tokens)는 각 플랫폼 처리 전에 structuredClone으로 복제되어, 한 플랫폼의 변환이 다른 플랫폼에 새지 않는다.
this.hooks 객체 하나 아래 모여 있다(v4부터).{ tokens, allTokens, tokenMap } 세 가지 형태(중첩 객체/평탄 배열/참조경로 맵)를 동시에 들고 다닌다. 포맷 함수는 이 dictionary를 받아 최종 파일 문자열을 만든다.이 절이 이 문서의 가장 두꺼운 부분이다. Style Dictionary를 "어떻게 만들어졌나" 관점에서 배우려면 아래 다섯 가지 설계를 순서대로 이해하면 된다.
토큰 값은 다른 토큰을 {color.brand}처럼 참조할 수 있다. 문제는 참조가 여러 단계로 이어질 수 있다는 것이다 — color.background.hover가 color.background.base를 참조하고, 그건 다시 color.brand를 참조하고, 그건 다시 color.base.green을 참조하는 식(코드 주석에 실제로 이 예시가 등장한다). 게다가 변환(transform)은 참조가 남아있는 값에는 적용할 수 없다 — 예를 들어 {color.brand}라는 문자열 자체를 16진수 색상 변환기에 넣으면 당연히 실패한다. 그래서 _exportPlatform()은 다음 구조로 돈다.
// lib/StyleDictionary.js _exportPlatform() 안, 실제 구조를 그대로 옮김
let finished = false;
while (!finished) {
// 1. 참조가 없는 값들만 골라 변환 시도 (transformMap)
await transformMap(tokenMap, platformConfig, opts, transformationContext);
// 2. 참조 문자열을 실제 값으로 치환 (resolveMap) — 순환 참조면 스택에서 잡힘
resolveMap(tokenMap, { ignorePaths: deferredPropValueTransforms, usesDtcg });
const newDeferredPropCount = deferredPropValueTransforms.size;
if (newDeferredPropCount === 0) {
finished = true; // 더 미룰 값이 없으면 끝
} else if (deferredPropCount === newDeferredPropCount) {
// 한 바퀴 돌았는데 미룬 개수가 줄지 않았다 = 순환 참조 확정
resolveMap(tokenMap, { usesDtcg }); // 에러/경고를 확정 발생시킴
finished = true;
} else {
deferredPropCount = newDeferredPropCount; // 아직 줄고 있으니 한 번 더
}
}
변환(transform)과 해석(resolve)을 번갈아 반복하는 것이 핵심이다: 참조가 없는 값부터 먼저 변환하고, 참조를 실제 값으로 채워 넣으면, 그 채워진 값 덕분에 이전엔 참조 때문에 미뤄뒀던(deferred) 다른 값이 다음 바퀴에는 변환 가능해진다. 이걸 "더 이상 풀 게 없을 때까지" 반복한다. 값이 몇 단계를 거쳐 참조되든(전이적 참조, transitive reference) 결국 다 풀리는 이유가 이 루프 때문이다.
여러 사람이 빚을 물고 물린 상황을 정산하는 것과 비슷하다. A는 B에게 갚아야 할 돈이 있고, B는 C에게 갚을 돈이 있다. C가 먼저 정산되면 B의 몫이 정해지고, B가 정산돼야 A의 몫도 정해진다. 한 번에 몰아서 계산할 수 없으니, "정산 가능한 사람부터 정산 → 남은 사람 확인 → 또 정산 가능한 사람 정산"을 더 이상 진전이 없을 때까지 반복하는 것이다. 만약 A→B→C→A처럼 돌고 도는 빚(순환 참조)이 있다면 영원히 못 끝나므로, 이걸 감지해 에러로 알린다.
실제 감지 로직은 lib/utils/references/resolveReferencesMap.js의 _resolveReferences()에 있다. 참조를 하나 따라갈 때마다 그 참조 문자열을 stack 배열에 쌓고, 다음 참조를 따라가기 전에 "이 참조가 이미 스택 안에 있는가"를 확인한다. 있다면 A가 결국 자기 자신으로 돌아온다는 뜻이므로 그 지점부터 스택 끝까지를 잘라 순환 구간으로 기록하고 경고를 등록한다.
// lib/utils/references/resolveReferencesMap.js 요지 (일부 생략)
if (Object.hasOwn(foundCirc, ref)) {
// 이미 순환으로 확인된 참조면 그냥 통과
} else if (stack.indexOf(ref) !== -1) {
// 스택에 이미 있다 = 순환 참조 발견!
const circStack = stack.slice(stack.indexOf(ref));
circStack.forEach((key) => { foundCirc[key] = true; });
// "Circular definition cycle for ..." 경고 생성
} else {
// 아직 안 밟은 참조 → 재귀적으로 더 따라간다
const nestedRef = _resolveReferences(ref, tokenMap, { stack, foundCirc, ... });
}
이 스택 방식 덕분에 참조가 몇 단계로 얽혀 있든(3단계든 30단계든) 순환이 발생한 정확한 경로("color.a → color.b → color.c → color.a")를 그대로 에러 메시지에 담아줄 수 있다. 막연히 "순환 참조가 있습니다"가 아니라 어디서 어디로 도는지 짚어준다는 게 실전에서 디버깅 시간을 크게 줄여준다.
lib/transform/token.js의 transformToken()이 토큰 하나에 등록된 변환 배열을 순서대로 훑으며, 각 변환의 type에 따라 완전히 다르게 취급한다.
| 타입 | 언제 실행되나 | 결과가 가는 곳 |
|---|---|---|
| value | 토큰의 원본 값에 참조가 없을 때만(또는 transitive:true로 등록된 변환이면 참조가 풀린 뒤에도 실행). 예: 16진수 → RGB 변환 | token.value(DTCG면 token.$value) |
| name | 필터 조건만 통과하면 항상 실행. 예: color.brand.primary → color-brand-primary(케밥 케이스) | token.name — 최종 출력에 쓰이는 변수/속성 이름 |
| attribute | 필터 조건만 통과하면 항상 실행. 예: 토큰 경로에서 category·type 같은 메타데이터 추출 | token.attributes — 필터링·정렬에 쓰이는 부가 정보 |
여기서 초보자가 자주 놓치는 지점이 바로 transitive 플래그다. 기본적으로 값에 참조가 남아있으면({color.brand}처럼) 그 값 변환은 아예 건너뛴다 — 참조 문자열 자체를 색상 변환기에 넣을 수 없기 때문이다. 하지만 "참조가 풀리고 난 뒤에도 이 변환을 한 번 더 적용하고 싶다"는 경우(예: 참조로 얻은 색상값에 CSS 단위를 덧붙이는 변환)를 위해 변환 등록 시 transitive: true를 켤 수 있다. 이 플래그가 없으면 참조 기반 토큰에는 해당 값 변환이 영원히 적용되지 않는다.
변환 함수가 도중에 에러를 던지면 어떻게 될까? v5.0.0부터는 _transformTokenWrapper()가 그 에러를 즉시 잡아서 경고 목록(GroupMessages)에 쌓아두고, 타입별로 안전한 대체값(name이면 원래 이름, attribute면 기존 attributes, value면 원래 값)을 돌려준다. 예전에는 변환 함수 하나가 죽으면 빌드 전체가 죽었지만, 지금은 문제 있는 토큰 하나만 경고로 남기고 나머지는 정상적으로 빌드가 끝난다(verbosity를 verbose로 올리면 어떤 토큰·어떤 변환에서 왜 실패했는지 전부 보인다).
lib/Register.js를 보면 StyleDictionary.registerTransform(...)처럼 클래스에 바로 등록하는 방법과, sdInstance.registerTransform(...)처럼 인스턴스에 등록하는 방법이 둘 다 있다. 내부적으로 hooks는 getter/setter로 정의돼 있어서, 읽을 때마다 "클래스 레벨의 기본 후크 + 이 인스턴스에만 등록된 후크"를 deepmerge로 매번 합쳐 돌려준다.
// lib/Register.js 요지
static hooks = getBuiltinHooks(); // 내장 62개 transform·44개 format 등이 여기 채워짐
get hooks() {
const ctor = this.constructor;
return deepmerge(ctor.hooks, this._hooks ?? {}); // 클래스+인스턴스 병합
}
이 구조 덕분에 StyleDictionary.registerFormat(...)으로 전역에 새 포맷을 등록해두면, 이후 만드는 모든 인스턴스가 그 포맷을 자동으로 물려받는다. 반대로 특정 빌드에서만 쓸 커스텀 변환은 인스턴스에 등록해 다른 빌드에 영향을 주지 않을 수 있다. 등록 함수(registerTransform·registerFormat·registerFilter·registerAction·registerParser·registerPreprocessor·registerFileHeader·registerTransformGroup)는 전부 이 패턴을 반복하며, 등록 전에 이름·타입·함수 여부를 검증해 잘못된 후크가 조용히 무시되는 일이 없게 한다.
Style Dictionary는 npm 패키지의 package.json exports 필드에서 "./fs" 서브패스를 Node 환경이면 lib/fs-node.js, 그 외(브라우저 등)면 lib/fs.js로 다르게 연결한다. lib/fs.js는 기본값으로 memfs(메모리 위에서 동작하는 가짜 파일시스템)를 쓰고, lib/fs-node.js는 그 자리를 진짜 node:fs로 바꿔치기한다.
// package.json exports 필드 (실제 값)
"./fs": {
"node": "./lib/fs-node.js", // Node에서 import하면 이걸 씀 → 진짜 파일 기록
"default": "./lib/fs.js" // 브라우저 등에서 import하면 이걸 씀 → 메모리 위에서만 동작
}
코어 로직(lib/StyleDictionary.js 등)은 파일을 다룰 때 항상 이 fs 객체(코드에서는 this.volume)를 통해서만 접근하고, "이게 진짜 디스크인지 메모리인지"는 신경 쓰지 않는다. 그 덕분에 같은 빌드 로직이 브라우저 안에서도 그대로 실행될 수 있다 — 실제로 공식 온라인 플레이그라운드(style-dictionary-play.dev)가 이 방식으로 브라우저 안에서 Style Dictionary를 통째로 돌린다. 이것이 v4에서 새로 생긴 "브라우저 지원"의 실체다.
파일시스템뿐 아니라 Style Dictionary 전반이 "실행 환경에 대한 가정"을 코어 밖으로 밀어내는 식으로 설계돼 있다. 이런 런타임 추상화는 CLI 도구를 만들 때 일반적으로 재사용 가능한 패턴이다 — 같은 로직을 터미널·브라우저·CI 서버 어디서든 굴리고 싶다면, "파일을 읽고 쓴다"는 동작 자체를 인터페이스 뒤로 숨겨야 한다.
Style Dictionary는 프레임워크 없이 순수 Node.js + ESM(ECMAScript Modules)로만 짜여 있다. package.json에 "type": "module"이 박혀 있고, 모든 소스 파일이 import/export 문법을 쓴다. 실행 의존성은 의도적으로 최소화돼 있다.
| 패키지 | 역할 |
|---|---|
| chalk | 터미널 로그에 색을 입힘(경고는 주황, 성공은 초록) |
| commander | CLI 명령어(build·clean) 파싱 |
| change-case | camelCase·kebab-case·snake_case 등 이름 변환(name 타입 변환의 실제 구현체) |
| tinycolor2 · colorjs.io | 색상 포맷 변환(HEX/RGB/HSL 등) 및 DTCG의 넓은 색공간(P3·LCH·OKLCH 등) 계산 |
| @bundled-es-modules/glob | 브라우저·Node 모두에서 동작하는 glob 패턴 매칭(source 경로 탐색) |
| @bundled-es-modules/memfs | 브라우저용 인메모리 파일시스템(5절 참고) |
| @bundled-es-modules/deepmerge | 설정·옵션 객체의 재귀적 병합(third-party 래핑) |
| is-plain-obj | 순수 객체 여부 판별(배열·클래스 인스턴스와 구분) |
| json5 · path-unified | 주석 허용 JSON 파싱 · OS 무관 POSIX 스타일 경로 처리 |
| prettier | 포맷 산출물(JS 등)을 필요 시 자동 정렬(동적 import로 필요할 때만 로드) |
| @zip.js/zip.js | 산출물을 zip으로 묶는 기능 지원 |
런타임 의존성이 13개뿐이라는 건 눈여겨볼 만하다. 대부분의 "무거운" 작업(테스트·문서 빌드·타입 검사)은 devDependencies로 분리돼 있고, 정작 npm으로 설치되어 실제 빌드에 쓰이는 코드는 가볍게 유지된다. Node.js 표준 API(node:path, node:fs)를 최대한 직접 쓰고, 꼭 필요한 부분만 외부 패키지에 기댄 흔적이 코드 곳곳에 보인다.
CHANGELOG.md의 4.0.0 항목을 그대로 인용하면 "프로젝트 전체가 ESM 포맷으로 완전히 전환됐다"고 적혀 있다. 이게 단순 문법 교체가 아니라 아래처럼 공개 API의 동작 자체를 바꿨다는 게 핵심이다.
StyleDictionary.extend()가 과거엔 동기 함수였지만 이제 Promise<StyleDictionary>를 반환한다. 실제 lib/StyleDictionary.js의 extend()·init()·buildAllPlatforms() 전부 async 함수로 선언돼 있고, 내부적으로 this.hasInitialized라는 프라미스로 "초기화가 끝나기 전엔 빌드 관련 메서드가 진행되지 않도록" 게이트를 건다.package.json의 exports 필드가 style-dictionary, style-dictionary/fs, style-dictionary/utils, style-dictionary/enums, style-dictionary/types 딱 다섯 곳만 외부에 공개한다. 예전처럼 style-dictionary/lib/아무파일.js을 깊게 import하는 방식은 v4 이후로 막혔다.fileHeader(단수) 같은 최상위 속성이 따로 있었지만, v4부터는 전부 hooks: { fileHeaders, parsers, transforms, ... } 하나의 객체 아래로 통합됐다(5절 ④ 참고).
| 항목 | 요구사항 |
|---|---|
| Node.js | 22.0.0 이상(package.json engines 명시). v5.0.0부터 참조 해석에 Set.prototype.union을 쓰기 때문에 이 버전 미만에서는 아예 동작하지 않는다. |
| 설치(CLI) | npm install --save-dev style-dictionary 후 npx style-dictionary build, 또는 전역 설치 후 style-dictionary build |
| 설치(모듈) | 같은 패키지를 import StyleDictionary from 'style-dictionary';로 스크립트 안에서 직접 제어 가능(빌드 자동화·커스텀 파이프라인에 유리) |
| 모듈 시스템 | ESM 전용. CommonJS(require) 프로젝트에서 쓰려면 동적 import()로 불러와야 한다. |
| 브라우저 | 번들러가 package.json exports의 조건(node vs default)을 제대로 인식해야 style-dictionary/fs가 올바른(memfs) 쪽으로 연결된다. |
v5.0.0에서 Node 최소 버전이 22로 올라간 건 기능 추가가 아니라 내부 구현 선택(Set.prototype.union 활용) 때문이다. 오래된 Docker 이미지나 CI 설정에서 Node 18/20을 그대로 쓰고 있었다면, 버전을 아예 신경 쓰지 않고 npm install을 최신으로 올리는 순간 빌드가 조용히 죽는다. package.json의 engines 필드를 먼저 확인하는 습관이 필요하다.
실제로 손을 움직이는 순서를 하나 골라 처음부터 끝까지 따라가 보자. 시나리오는 "브랜드 파란색 하나를 CSS 변수와 iOS Swift 상수로 동시에 뽑아내기"다.
tokens/color.json{
"color": {
"base": {
"blue": { "value": "#3452eb" }
},
"brand": {
"primary": { "value": "{color.base.blue}" }
}
}
}
color.brand.primary가 color.base.blue를 중괄호 참조로 가리킨다는 점에 주목하자. 이렇게 해두면 "기본 파랑"을 한 곳에서 관리하면서, "브랜드 색"이라는 의미 있는 이름으로도 참조할 수 있다.
config.json{
"source": ["tokens/**/*.json"],
"platforms": {
"web": {
"transformGroup": "css",
"buildPath": "build/web/",
"files": [{ "destination": "variables.css", "format": "css/variables" }]
},
"ios": {
"transformGroup": "ios-swift",
"buildPath": "build/ios/",
"files": [{ "destination": "StyleDictionary.swift", "format": "ios-swift/class.swift" }]
}
}
}
transformGroup이 4절의 web/ios 묶음(각각 여러 개별 변환의 조합)을 한 단어로 지정하는 지점이다. 각 files 항목이 "어떤 포맷 함수로, 어디에" 산출물을 쓸지를 정한다.
# 터미널에서
npx style-dictionary build --config config.json
내부적으로는 bin/style-dictionary.js(CLI 진입점)가 config.json을 읽어 new StyleDictionary(config)로 인스턴스를 만들고, await sd.buildAllPlatforms()를 호출한다. 이 한 호출이 4절의 파이프라인 전체(파싱→병합→전처리→평탄화→변환 루프→필터→포맷→쓰기)를 web·ios 두 플랫폼에 대해 각각(병렬로) 실행한다.
같은 원본 #3452eb 하나가, 웹에서는 그대로 헥사 문자열로, iOS에서는 0~1 사이 부동소수점 UIColor 생성자 호출로 각기 다른 문법으로 도착했다. 토큰 파일을 고치는 순간 두 산출물이 동시에 다시 생성되니, "웹은 반영됐는데 iOS는 깜빡했다" 같은 사고가 구조적으로 불가능해진다.
await 없이 체이닝하기
인터넷에 남아있는 오래된 예제 코드는 StyleDictionary.extend(config).buildAllPlatforms()를 동기 체이닝처럼 그대로 쓴다. 하지만 v4 이후 extend()는 Promise를 반환하므로, await 없이 바로 .buildAllPlatforms()를 붙이면 Promise 객체에 없는 메서드를 부르는 셈이라 즉시 에러가 난다. 항상 const sd = await new StyleDictionary(config).hasInitialized; 또는 생성자에 init:false를 주고 await sd.init() 후 사용하는 패턴을 따라야 한다.
5절 ③에서 봤듯, 값에 {...} 참조가 남아있는 동안엔 값 변환이 건너뛰어진다. 커스텀 변환을 등록했는데 참조를 쓰는 토큰에서만 유독 적용이 안 되는 것처럼 보인다면, 원인은 대부분 transitive: true를 빼먹은 것이다. "참조가 풀린 뒤에도 이 변환을 계속 적용하고 싶다"는 의도가 있다면 반드시 이 플래그를 명시해야 한다.
attribute/cti+name/kebab 같은 변환 조합은 토큰 경로에서 카테고리 정보를 깎아낸다. 그 결과 서로 다른 토큰 두 개가 같은 출력 이름으로 겹칠 수 있는데, 기본 로그 설정에서는 콘솔에 경고만 찍고 빌드는 그대로 성공한다. CI 로그를 꼼꼼히 안 보면 "산출물에 값이 하나만 남고 하나는 조용히 덮어써진" 사고를 눈치채지 못한다. log.verbosity: "verbose"로 정확히 어떤 토큰들이 충돌했는지 보거나, log.warnings: "error"로 아예 빌드를 실패시켜 조기에 걸러내는 걸 권한다.
deepExtend와 deepmerge는 이름만 비슷하지 다른 함수다
lib/utils/deepExtend.js(토큰 파일 병합 전용, 충돌 콜백·파일 경로 태깅 기능이 붙은 자체 구현)와 lib/utils/deepmerge.js(설정·옵션 객체 병합용, 서드파티 deepmerge 패키지를 감싼 것)는 서로 다른 목적의 별개 함수다. 후자는 배열을 합칠 때 중복 값을 제거(dedupe)하지만 전자는 그렇지 않다. 소스를 읽다가 두 함수를 같은 것으로 착각하면 병합 결과를 잘못 예측하게 된다.
package.json의 exports 필드가 공개 진입점을 style-dictionary·style-dictionary/fs·style-dictionary/utils·style-dictionary/enums·style-dictionary/types 다섯 개로 못 박아뒀다. 예전 버전에서 통했던 style-dictionary/lib/somefile.js 같은 깊은 경로 import는 v4 이후 Node.js가 아예 모듈 해석 단계에서 거부한다. 필요한 유틸을 못 찾겠다면 style-dictionary/utils·style-dictionary/enums 진입점부터 확인해야 한다.
공식 문서 자체는 최신 비동기 문법으로 대부분 갱신돼 있지만, 블로그 글·스택오버플로 답변·오래된 보일러플레이트 저장소에는 여전히 v3 시절의 동기 체이닝 예제가 많이 떠돈다. 이 문서를 포함해 Style Dictionary 관련 자료를 볼 때는 항상 버전을 먼저 확인하는 습관이 필요하다 — v4 이전/이후 예제는 API 시그니처 자체가 다르다.
| 도구 | Style Dictionary와 다른 점 |
|---|---|
| Terrazzo (구 Cobalt UI) |
후발주자 토큰 빌드 도구. 처음부터 W3C DTCG 포맷을 기본 전제로 설계돼 $value/$type 지원이 더 "네이티브"하고, 플러그인 API가 상대적으로 단순하다. 반면 Style Dictionary는 역사가 길어 지원 플랫폼·포맷의 폭(62개 변환·44개 포맷)과 커뮤니티 자산이 압도적으로 많다. "표준을 처음부터 깔끔하게 vs 검증된 범용성" 구도다. |
| Theo | 세일즈포스 라이트닝 디자인 시스템에서 나온 원조 격 토큰 빌드 도구. Style Dictionary가 등장하며 사실상 그 자리를 이어받았고, Theo 자체는 지금은 활발히 유지보수되지 않는다. "이 장르의 원조"로서 개념적 조상에 가깝다. |
| Tokens Studio (Figma 플러그인) |
빌드 도구가 아니라 토큰을 "만드는" Figma 플러그인이다. 디자이너가 Figma 안에서 토큰을 관리하고 JSON으로 내보내면, 그 JSON을 실제 코드로 바꾸는 건 보통 Style Dictionary가 맡는다. 경쟁 관계가 아니라 저작(Tokens Studio) → 빌드(Style Dictionary)로 이어지는 파이프라인의 앞뒤 단계다. |
| Panda CSS | 디자인 토큰을 자체 스타일링 시스템(아토믹 CSS + JSX/TSX 타입 안전 스타일)에 내장한 CSS-in-JS 계열 프레임워크. 토큰 개념은 있지만 웹 프론트엔드 하나에 최적화돼 있고, 그 프레임워크를 채택해야 토큰도 같이 쓸 수 있다. Style Dictionary는 특정 프레임워크에 종속되지 않고 iOS·Android·웹·Flutter를 동등하게 대상으로 삼는다는 점이 다르다. |
Tokens Studio가 원고를 쓰는 작가라면, Style Dictionary·Terrazzo는 그 원고를 여러 나라 말로 옮기는 번역 출판사고, Panda CSS는 한 나라(웹) 안에서만 활동하는 전속 인쇄소에 가깝다. Theo는 이 번역 출판업의 원조 격 회사였지만 지금은 사실상 문을 닫은 상태다.
가장 명확한 사용처다. 색상·간격·타이포그래피가 플랫폼마다 각기 다른 코드 문법으로 필요하다면, Style Dictionary의 "원본 하나·산출물 여럿" 구조가 정확히 그 문제를 겨냥한다.
내장 변환 62개·포맷 44개로도 대개는 충분하지만, 회사만의 독특한 산출물 형식(사내 DSL, 특수 파일 포맷 등)이 필요하다면 registerTransform·registerFormat으로 원하는 만큼 확장할 수 있다. 5절에서 본 후크 등록 메커니즘이 바로 그 확장 지점이다.
웹 하나만 대상으로 하고 이미 Panda CSS 같은 프레임워크의 토큰 기능을 쓰고 있다면, 굳이 별도의 빌드 파이프라인을 얹을 필요는 적다. 이럴 땐 그 프레임워크 자체의 토큰 기능을 쓰는 편이 배울 것도 적고 자연스럽다.
요약하면 Style Dictionary는 "디자인 값의 단일 진실 공급원을 여러 플랫폼 코드로 안전하게 번역하는" 문제를, 참조 해석 루프·순환 참조 감지·타입별 변환 분리·이중 파일시스템 같은 견고한 엔지니어링으로 오랫동안 풀어온 도구다. 화려한 신기능보다 "이미 쓰이고 있는 값이 절대 틀리지 않게" 만드는 데 집중한다는 점이 이 레포에서 배울 수 있는 가장 큰 태도다.