REPO DEEP DIVE · 2026-07-29 · STYLE-DICTIONARY/STYLE-DICTIONARY · 디자인 토큰을 모든 플랫폼의 언어로 번역하는 컴파일러

style-dictionary 딥다이브
"디자인 토큰 하나"를 CSS·Swift·Kotlin·JS로 동시에 찍어내는 빌드 시스템

Style Dictionary는 색상·간격·타이포그래피 같은 디자인 토큰(design token)을 JSON/YAML 같은 플랫폼 중립적인 원본으로 한 번만 정의해두면, 이를 iOS(Swift)·Android(Kotlin/XML)·웹(CSS·SCSS·JS·TS)·Flutter 등 각 플랫폼이 알아듣는 코드로 자동 변환해주는 빌드 도구(컴파일러)다. 디자이너와 개발자 사이에 "그 파란색이 정확히 몇 번 파란색이었죠?"라는 질문이 영원히 반복되는 문제를, "원본은 하나, 산출물은 여럿"이라는 원칙으로 없앤다.

이 문서가 다루는 건 겉모습이 아니라 실제 소스코드 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개)
목차
  1. 한 줄 정체
  2. 왜 지금 뜨는가
  3. 무엇을 해결하나
  4. 전체 구조
  5. 핵심 설계·동작 원리
  6. 기술 스택·의존성
  7. 설치·요구사항
  8. 실제 사용 흐름
  9. 함정·오해 방지
  10. 대안 비교
  11. 정리 · 누가 쓰면 좋은가

1한 줄 정체

"디자인 토큰 JSON 하나 → CSS·Swift·Kotlin·JS·Flutter 코드 여럿"을 찍어내는 번역기 겸 빌드 파이프라인

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해서 스크립트 안에서 직접 제어할 수도 있다. 코드 자체를 그리는 디자인 툴이 아니라, "이미 정해진 디자인 값을 각 플랫폼의 코드로 번역해 옮겨 적는" 순수한 빌드 계층이라는 점이 정체성의 핵심이다.

용어
디자인 토큰 (Design Token)
색상·간격·글꼴 크기·그림자처럼 디자인을 구성하는 가장 작은 값에 이름을 붙인 것. 예를 들어 color.brand.primary = "#3452eb". 코드에 값을 직접 박아넣는(hard-code) 대신 이름으로 참조하게 해, 값이 바뀌어도 이름 하나만 고치면 모든 곳에 반영되게 한다.
용어
빌드 시스템 (build system)
원본 파일을 읽어 규칙에 따라 가공한 뒤, 실제로 쓰이는 산출물(파일)을 만들어내는 도구. 웹 개발의 Webpack/Vite와 비슷한 위치이지만, Style Dictionary는 자바스크립트 코드가 아니라 "디자인 값"을 산출물로 컴파일한다.
한 줄 비유

"만국 공통 단어장" 하나로 여러 나라 말을 동시에 옮겨 적는 번역가

디자인 토큰은 모든 번역의 원본이 되는 단어장이다. "brand-blue"라는 표제어 하나에 뜻(#3452eb)이 하나 달려 있다.

Style Dictionary는 이 단어장을 들고 iOS 통역사, 안드로이드 통역사, 웹 통역사에게 동시에 넘겨준다. 통역사마다 같은 표제어를 자기 나라 말투(Swift 상수, XML 리소스, CSS 변수)로 옮겨 적을 뿐, 원본 뜻은 절대 바뀌지 않는다. 단어장을 고치면 세 통역사의 번역문이 전부 자동으로 갱신된다.

2왜 지금 뜨는가

디자인 시스템의 표준 인프라화 · W3C 토큰 표준 등장 · AI 코드 생성 시대의 "정답 소스"

① 디자인 시스템이 회사 규모와 무관하게 필수가 됐다

스타트업이든 대기업이든 앱을 iOS·Android·웹으로 동시에 내는 게 기본값이 된 지 오래다. 그런데 각 플랫폼 개발자가 "브랜드 파란색"을 각자 따로 하드코딩하면, 리브랜딩 한 번에 세 팀이 따로 값을 찾아 바꿔야 한다. Style Dictionary는 "값의 원본은 하나"라는 원칙을 강제해 이 반복 작업과 불일치를 구조적으로 없앤다. 그래서 디자인 시스템을 운영하는 조직 대부분이 이 도구이거나 이 도구의 개념을 그대로 흉내 낸 사내 도구를 쓴다.

② W3C 디자인 토큰 표준(DTCG)이 실제로 굳어지는 중이다

디자인 토큰 생태계는 오랫동안 "회사마다 JSON 형식이 제각각"이라는 문제를 겪었다. W3C 커뮤니티 그룹이 만드는 DTCG(Design Tokens Community Group) 표준($value/$type 문법)이 자리를 잡으면서, Figma의 Tokens Studio 플러그인 같은 토큰 저작 도구와 Style Dictionary 같은 토큰 빌드 도구가 같은 포맷으로 대화할 수 있게 됐다. Style Dictionary는 이 표준을 소스 레벨에서 실제로 계속 뒤쫓아 구현하고 있다(4절에서 코드로 확인).

③ AI 코드 생성 시대에 "출처가 확실한 값"의 가치가 커졌다

AI 코딩 도구가 UI 코드를 빠르게 뽑아낼수록, 정작 "이 색이 진짜 맞는 색인가"를 검증할 단일 진실 공급원(single source of truth)의 가치가 커진다. Style Dictionary로 빌드된 산출물은 사람이든 AI든 "이 변수를 쓰면 틀릴 수 없다"는 확실성을 주기 때문에, 최근 디자인 시스템/토큰 관련 오픈소스가 다시 활발해진 흐름과 함께 재조명받고 있다.

경쟁 지형에서의 위치
가장 오래됐고, 가장 넓게 퍼진 "토큰 컴파일러"

Theo(옛 세일즈포스 도구)가 사실상 뒤로 물러난 자리를 이어받아, 가장 오래 실전에서 검증된 토큰 빌드 도구로 자리잡았다. 이후 등장한 Terrazzo/Cobalt 같은 신생 도구들이 "더 단순한 플러그인 구조"를 내세우며 도전하고 있지만(10절), Style Dictionary는 지원 플랫폼의 폭과 커스텀 확장성에서 여전히 기본 선택지로 꼽힌다.

3무엇을 해결하나

"같은 값, 다른 이름"이 플랫폼마다 흩어지는 문제 · 디자인-개발 사이의 수동 동기화

Style Dictionary가 없는 세상을 먼저 상상해보자. 디자이너가 Figma에서 브랜드 파란색을 #3452EB로 정했다고 치자. 이 값을 실제 제품에 반영하려면:

세 곳 모두 같은 값을 세 번, 서로 다른 문법으로 손으로 옮겨 적은 것이다. 브랜드 색이 바뀌면 세 곳을 다 찾아 고쳐야 하고, 그중 하나라도 놓치면 "웹은 새 파랑인데 앱은 옛날 파랑"인 불일치가 생긴다. 이게 실제로 디자인 시스템 팀들이 매일 겪던 문제였다.

Style Dictionary는 이 흐름을 뒤집는다. 값은 토큰 JSON에 딱 한 번만 적고, "iOS는 0~1 부동소수점으로, Android는 #AARRGGBB 8자리 헥사로, 웹은 #rrggbb 그대로"라는 변환 규칙을 도구에게 등록해두면, 빌드 명령 한 번에 세 파일이 동시에, 항상 같은 원본에서 정확히 생성된다. 사람이 값을 옮겨 적으며 실수할 자리가 원천적으로 사라진다.

비유

환율 계산을 손으로 하지 않는 이유와 같다. 원화 1000원이 달러로 얼마인지 나라마다 사람이 암산해서 장부에 적으면 언젠가 계산 실수가 난다. 대신 "오늘 환율표(원본)"를 딱 하나 두고, 각 통화별 계산 공식(변환 규칙)을 프로그램에 맡기면 장부는 항상 정확하고 일관된다. 토큰 원본이 환율표, 플랫폼별 변환 규칙이 환전 공식, 산출 파일들이 각국 화폐로 찍힌 최종 장부다.

4전체 구조

parsers → 딥 머지 → preprocessors → transforms(value/name/attribute) → resolve → filters → formats → actions

Style Dictionary의 몸통은 lib/StyleDictionary.js(984줄) 하나의 클래스다. 이 클래스가 Register(lib/Register.js, 후크 등록 담당)를 상속받고, 나머지 기능은 lib/utils/*·lib/transform/*·lib/common/*에 흩어진 순수 함수들을 불러 쓰는 구조다. 전체 흐름을 독수리 시점으로 그리면 이렇다.

┌────────────────────────────────────────────────────────────────────┐ │ STYLE DICTIONARY 빌드 파이프라인 (lib/) │ └────────────────────────────────────────────────────────────────────┘ [입력] config.json / config.js (source, platforms, hooks 등) │ ▼ 1) 토큰 파일 수집·병합 combineJSON.js · parsers: 파일 확장자 패턴에 맞는 커스텀 파서 적용(없으면 기본 JSON/JSON5/YAML 로더) · inline tokens + include(낮은 우선순위) + source(높은 우선순위)를 deepExtend.js로 깊은 병합 → 충돌 시 경고 수집 │ ▼ 2) 전처리 utils/preprocess.js · preprocessors 등록 함수들을 순서대로 실행(트리 전체 단위) · DTCG 문법이면 typeDtcgDelegate.js가 그룹의 $type을 하위 토큰까지 전파 │ ▼ 3) 평탄화 utils/convertTokenData.js · 중첩 객체 트리 → allTokens 배열 + tokenMap(참조경로 "{a.b.c}" → 토큰) · (옵션) expandObjectTokens.js: 그림자/타이포그래피 등 객체형 토큰을 하위 속성별 개별 토큰으로 펼침(expand) │ ▼ ── 플랫폼(platform)별로 아래 4)~6)을 반복 ── 4) 변환 루프 StyleDictionary.js _exportPlatform() 안의 while(!finished) · transformMap → transformToken (transform/token.js) value 변환: 참조({..})가 없는 값에만 적용(또는 transitive=true) name 변환: 토큰 최종 출력 이름 결정 (예: nameKebab) attribute 변환: category/type 등 메타데이터 부착 · resolveMap → 참조("{color.brand}") 치환, 순환 참조는 스택으로 감지 · 아직 못 푼 참조가 있으면 defer 후 다시 4)로 (참조 사슬이 풀릴 때까지) │ ▼ 5) 필터링 filterTokens.js · 파일별 filter(함수/객체)로 이 플랫폼·이 파일에 필요한 토큰만 추림 │ ▼ 6) 포맷팅 common/formats.js 안의 format 함수 실행 · 필터링된 dictionary를 실제 문자열(.swift, .xml, .css, .js …)로 렌더링 │ ▼ [출력] buildPlatform() → volume.promises.writeFile()로 실제 파일 기록 │ ▼ 7) 액션(선택) common/actions.js · 파일 쓰기 이후 부가 작업(예: 이미지 에셋을 안드로이드 res 폴더로 복사)

주목할 점은 3)~6)이 "플랫폼마다 통째로 다시 실행"된다는 것이다. 같은 토큰이라도 iOS 플랫폼에서는 ios transformGroup으로, 웹 플랫폼에서는 css transformGroup으로 완전히 다른 값·이름이 나온다. 원본 토큰 트리(this.tokens)는 각 플랫폼 처리 전에 structuredClone으로 복제되어, 한 플랫폼의 변환이 다른 플랫폼에 새지 않는다.

용어
후크 (hooks)
Style Dictionary가 외부에서 끼워넣을 수 있게 열어둔 확장 지점의 총칭. parsers·preprocessors·transforms·transformGroups·formats·fileHeaders·filters·actions 8종류가 있으며, 전부 this.hooks 객체 하나 아래 모여 있다(v4부터).
용어
딕셔너리 (dictionary)
이 프로젝트 안에서는 "변환이 끝난 토큰들의 모음"을 가리키는 내부 용어. { tokens, allTokens, tokenMap } 세 가지 형태(중첩 객체/평탄 배열/참조경로 맵)를 동시에 들고 다닌다. 포맷 함수는 이 dictionary를 받아 최종 파일 문자열을 만든다.

5핵심 설계·동작 원리

참조 해석 루프 · 순환 참조 감지 · 변환 3종(value/name/attribute) · 후크 등록 메커니즘 · 브라우저 이중 파일시스템

이 절이 이 문서의 가장 두꺼운 부분이다. Style Dictionary를 "어떻게 만들어졌나" 관점에서 배우려면 아래 다섯 가지 설계를 순서대로 이해하면 된다.

① 참조 해석은 "한 번에 끝나는 계산"이 아니라 "수렴할 때까지 도는 루프"다

토큰 값은 다른 토큰을 {color.brand}처럼 참조할 수 있다. 문제는 참조가 여러 단계로 이어질 수 있다는 것이다 — color.background.hovercolor.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")를 그대로 에러 메시지에 담아줄 수 있다. 막연히 "순환 참조가 있습니다"가 아니라 어디서 어디로 도는지 짚어준다는 게 실전에서 디버깅 시간을 크게 줄여준다.

③ 변환은 셋으로 나뉘어 각기 다른 시점·조건에 실행된다 (value / name / attribute)

lib/transform/token.jstransformToken()이 토큰 하나에 등록된 변환 배열을 순서대로 훑으며, 각 변환의 type에 따라 완전히 다르게 취급한다.

타입언제 실행되나결과가 가는 곳
value토큰의 원본 값에 참조가 없을 때만(또는 transitive:true로 등록된 변환이면 참조가 풀린 뒤에도 실행). 예: 16진수 → RGB 변환token.value(DTCG면 token.$value)
name필터 조건만 통과하면 항상 실행. 예: color.brand.primarycolor-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로 올리면 어떤 토큰·어떤 변환에서 왜 실패했는지 전부 보인다).

④ 후크 등록은 "정적 클래스 값 + 인스턴스 값을 deepmerge로 합치는" 이중 구조

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)는 전부 이 패턴을 반복하며, 등록 전에 이름·타입·함수 여부를 검증해 잘못된 후크가 조용히 무시되는 일이 없게 한다.

⑤ 같은 코어가 Node.js와 브라우저에서 동시에 돌아가는 이유 — 파일시스템 이중화

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 서버 어디서든 굴리고 싶다면, "파일을 읽고 쓴다"는 동작 자체를 인터페이스 뒤로 숨겨야 한다.

6기술 스택·의존성

ESM 순수 Node.js 패키지 · 런타임 의존성 13개 · v4의 ESM 전환이 남긴 흔적

Style Dictionary는 프레임워크 없이 순수 Node.js + ESM(ECMAScript Modules)로만 짜여 있다. package.json"type": "module"이 박혀 있고, 모든 소스 파일이 import/export 문법을 쓴다. 실행 의존성은 의도적으로 최소화돼 있다.

패키지역할
chalk터미널 로그에 색을 입힘(경고는 주황, 성공은 초록)
commanderCLI 명령어(build·clean) 파싱
change-casecamelCase·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)를 최대한 직접 쓰고, 꼭 필요한 부분만 외부 패키지에 기댄 흔적이 코드 곳곳에 보인다.

v4 ESM 전환이 코드에 남긴 흔적

CHANGELOG.md의 4.0.0 항목을 그대로 인용하면 "프로젝트 전체가 ESM 포맷으로 완전히 전환됐다"고 적혀 있다. 이게 단순 문법 교체가 아니라 아래처럼 공개 API의 동작 자체를 바꿨다는 게 핵심이다.

7설치·요구사항

Node.js 22 이상 필수 · npm 전역/로컬 설치 · CLI 또는 모듈 import 두 갈래
항목요구사항
Node.js22.0.0 이상(package.json engines 명시). v5.0.0부터 참조 해석에 Set.prototype.union을 쓰기 때문에 이 버전 미만에서는 아예 동작하지 않는다.
설치(CLI)npm install --save-dev style-dictionarynpx 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) 쪽으로 연결된다.
주의
Node 22 미만 CI 파이프라인은 그대로 깨진다

v5.0.0에서 Node 최소 버전이 22로 올라간 건 기능 추가가 아니라 내부 구현 선택(Set.prototype.union 활용) 때문이다. 오래된 Docker 이미지나 CI 설정에서 Node 18/20을 그대로 쓰고 있었다면, 버전을 아예 신경 쓰지 않고 npm install을 최신으로 올리는 순간 빌드가 조용히 죽는다. package.jsonengines 필드를 먼저 확인하는 습관이 필요하다.

8실제 사용 흐름

토큰 JSON 작성 → config로 플랫폼·변환·파일 지정 → build 실행 → 산출물 확인, 한 줄기를 끝까지

실제로 손을 움직이는 순서를 하나 골라 처음부터 끝까지 따라가 보자. 시나리오는 "브랜드 파란색 하나를 CSS 변수와 iOS Swift 상수로 동시에 뽑아내기"다.

① 토큰 파일 작성 — tokens/color.json

{
  "color": {
    "base": {
      "blue": { "value": "#3452eb" }
    },
    "brand": {
      "primary": { "value": "{color.base.blue}" }
    }
  }
}

color.brand.primarycolor.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 두 플랫폼에 대해 각각(병렬로) 실행한다.

④ 산출물 확인

build/web/variables.css :root { --color-base-blue: #3452eb; --color-brand-primary: #3452eb; ← 참조가 실제 값으로 풀려서 출력됨 } build/ios/StyleDictionary.swift class StyleDictionary { static let colorBaseBlue = UIColor(red: 0.204, green: 0.322, blue: 0.922, alpha: 1) static let colorBrandPrimary = UIColor(red: 0.204, green: 0.322, blue: 0.922, alpha: 1) }

같은 원본 #3452eb 하나가, 웹에서는 그대로 헥사 문자열로, iOS에서는 0~1 사이 부동소수점 UIColor 생성자 호출로 각기 다른 문법으로 도착했다. 토큰 파일을 고치는 순간 두 산출물이 동시에 다시 생성되니, "웹은 반영됐는데 iOS는 깜빡했다" 같은 사고가 구조적으로 불가능해진다.

9함정·오해 방지

비동기 API를 잊고 쓰기 · transitive 플래그 누락 · 이름 충돌 · 두 개의 서로 다른 병합 함수 · 깊은 import 금지

함정 ① — v3 시절 튜토리얼처럼 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"로 아예 빌드를 실패시켜 조기에 걸러내는 걸 권한다.

함정 ④ — deepExtenddeepmerge는 이름만 비슷하지 다른 함수다

lib/utils/deepExtend.js(토큰 파일 병합 전용, 충돌 콜백·파일 경로 태깅 기능이 붙은 자체 구현)와 lib/utils/deepmerge.js(설정·옵션 객체 병합용, 서드파티 deepmerge 패키지를 감싼 것)는 서로 다른 목적의 별개 함수다. 후자는 배열을 합칠 때 중복 값을 제거(dedupe)하지만 전자는 그렇지 않다. 소스를 읽다가 두 함수를 같은 것으로 착각하면 병합 결과를 잘못 예측하게 된다.

함정 ⑤ — 패키지 깊은 import는 v4부터 막혀 있다

package.jsonexports 필드가 공개 진입점을 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 진입점부터 확인해야 한다.

문서와 코드가 실제로 어긋나는 지점
"동기 API"로 보이는 예전 스니펫들

공식 문서 자체는 최신 비동기 문법으로 대부분 갱신돼 있지만, 블로그 글·스택오버플로 답변·오래된 보일러플레이트 저장소에는 여전히 v3 시절의 동기 체이닝 예제가 많이 떠돈다. 이 문서를 포함해 Style Dictionary 관련 자료를 볼 때는 항상 버전을 먼저 확인하는 습관이 필요하다 — v4 이전/이후 예제는 API 시그니처 자체가 다르다.

10대안 비교

Terrazzo(Cobalt UI) · Theo · Tokens Studio · Panda CSS — 겹치는 부분과 다른 부분
도구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는 이 번역 출판업의 원조 격 회사였지만 지금은 사실상 문을 닫은 상태다.

11정리 · 누가 쓰면 좋은가

디자인 시스템을 2개 이상 플랫폼에 동시에 공급해야 하는 팀 · 커스텀 확장이 필요한 엔지니어
이런 사람에게 잘 맞는다

2개 이상의 플랫폼(iOS+Android+웹 등)에 같은 디자인 값을 공급해야 하는 팀

가장 명확한 사용처다. 색상·간격·타이포그래피가 플랫폼마다 각기 다른 코드 문법으로 필요하다면, Style Dictionary의 "원본 하나·산출물 여럿" 구조가 정확히 그 문제를 겨냥한다.

이런 사람에게 잘 맞는다

표준 변환·포맷으로 부족해 직접 확장하고 싶은 엔지니어

내장 변환 62개·포맷 44개로도 대개는 충분하지만, 회사만의 독특한 산출물 형식(사내 DSL, 특수 파일 포맷 등)이 필요하다면 registerTransform·registerFormat으로 원하는 만큼 확장할 수 있다. 5절에서 본 후크 등록 메커니즘이 바로 그 확장 지점이다.

이런 사람에게는 과할 수도 있다

플랫폼이 하나(웹)뿐이고, 이미 특정 CSS 프레임워크에 깊이 묶여 있다면

웹 하나만 대상으로 하고 이미 Panda CSS 같은 프레임워크의 토큰 기능을 쓰고 있다면, 굳이 별도의 빌드 파이프라인을 얹을 필요는 적다. 이럴 땐 그 프레임워크 자체의 토큰 기능을 쓰는 편이 배울 것도 적고 자연스럽다.

요약하면 Style Dictionary는 "디자인 값의 단일 진실 공급원을 여러 플랫폼 코드로 안전하게 번역하는" 문제를, 참조 해석 루프·순환 참조 감지·타입별 변환 분리·이중 파일시스템 같은 견고한 엔지니어링으로 오랫동안 풀어온 도구다. 화려한 신기능보다 "이미 쓰이고 있는 값이 절대 틀리지 않게" 만드는 데 집중한다는 점이 이 레포에서 배울 수 있는 가장 큰 태도다.