디자인 시스템 시리즈 1편. 우리 프론트엔드가 왜 이렇게 파편화됐는지, 그걸 토큰부터 풀기로 한 이야기다.

시작은 “레포가 몇 개죠?”

디자인은 하나인데 구현은 여러 개다. 이게 무슨 말이냐면, 웹회사(v2) 말고도 앱웹 화면용 레포가 5개 넘게 따로 산다. 버튼 하나, 컬러 하나가 레포마다 각자 하드코딩돼 있다는 뜻이다.

상담신청 페이지 하나를 고치려고 했더니 최소 4개, 최대 6개 레포를 건드려야 했다. 엥..? 같은 화면인데 왜 여기저기 흩어져 있지. 심지어 스타일링 방식도 제각각이었다. 한 레포(관리자 웹)만 봐도 tailwind(그것도 2.x EOL) + emotion + styled-components + MUI5 + material-ui4 + antd + sass, 무려 7종이 동시에 돌고 있었다. 다른 레포도 5종씩. 버전은 또 어떻고 — Next 13.3부터 15.5까지, React 1819, react-query v3v5가 뒤섞여 있었다.

이 상황을 사내 위키(“FE 현재 상황 및 기술부채”)에 정리하면서, 근본 원인이 명확해졌다. 디자인의 “단일 진실 공급원(single source of truth)“이 없다. 색·간격·타이포가 코드 곳곳에 매직 넘버로 박혀 있으니, 디자인이 한 번 바뀌면 레포 6개를 순회하며 손으로 맞춰야 한다.

기존 레포는 빼고, 토큰부터 새로 판다

디자인 시스템 시도가 처음은 아니었다. 예전에 만든 컴포넌트 라이브러리 레포가 하나 있긴 했는데, 이게 메모리 누수가 나고 무겁기까지 해서 오히려 짐이었다. 그래서 그 레포는 일단 걷어내기로 했다. 대신 내가 새 레포를 파고, 거기에 tokens.json부터 직접 넣기 시작했다. 컴포넌트를 다시 그리기 전에 모두가 공유할 값(토큰)부터 세우는 게 순서라고 봤다. 그래서 새로 판 레포의 현재 상태는 이렇다.

  • 아직 컴포넌트는 없다. 내가 tokens.json 딱 하나만 넣었다. (Figma Tokens Studio가 뱉은 덤프를 그대로)
  • package.json에 Style Dictionary 의존성만 걸어뒀고, 빌드 스크립트도 config도 아직 없다.
  • 그러니 산출물 변환도, npm 배포도 아직 없다. 즉 아직 아무 레포도 이걸 import 하지 않는다 — 이제 막 씨앗만 심은 상태니까.

한마디로 지금은 씨앗만 있는 상태다. 방향(모노레포 최상단에 디자인 시스템)은 맞지만, 현실은 토큰 덤프 파일 하나가 덩그러니 있는 채택률 0%. Figma에서 예쁘게 관리되는 토큰이 아직 코드까지 한 발짝도 못 오고 있었다. 이 글은 그 0%를 1%로 만든 기록이다.

💡 토큰 파일이 “있다”와 토큰이 “쓰인다”는 완전히 다른 얘기다. 파이프라인이 없으면 그냥 JSON 무덤.

목표: 0%를 1%로

야심 차게 “전 레포 통합!”을 외치고 싶었지만, 그건 함정이다. 레포 6개를 한 번에 바꾸려다 아무것도 못 바꾸는 그림이 뻔했다. 그래서 목표를 아주 작게 잡았다.

tokens.json 덤프 → 실제 빌드 파이프라인(Style Dictionary) → npm 배포 → 1개 레포 시범 소비. 채택 0%를 1%로.

숫자로 1%는 초라해 보인다. 근데 0과 1 사이엔 파이프라인 전체가 들어간다. “표준을 먼저 세우고, 채택은 점진적으로.” 이게 이 글의 전부다.

왜 표준부터? 왜 점진 채택?

  • 표준부터인 이유: 소비할 산출물(CSS 변수/JS 상수)이 없으면 어느 레포도 붙을 수가 없다. 공급이 먼저다. 배포 가능한 결과물을 만들어 놔야 “그럼 우리 레포도 써볼까”가 성립한다.
  • 점진 채택인 이유: 7종 스타일링이 얽힌 레포를 한 번에 뒤엎으면 리스크가 통제 불능이 된다. 1개 레포로 파이프라인이 실제로 도는지(토큰 바꾸면 → 배포 → 화면 반영) 검증부터 하는 게 안전하다. 성공 사례 하나가 두 번째 레포를 설득한다.

왜 Style Dictionary인가

토큰 하나를 웹은 CSS 변수로, JS는 상수로, 나중엔 Flutter/iOS까지 각 플랫폼 포맷으로 뿌려야 한다. 이걸 손으로 관리하면 그 자체가 또 다른 파편화다. Style Dictionary는 “토큰 원본 1개 → 플랫폼별 산출물 N개” 변환을 담당하는 표준 도구다.

  • 원본은 하나(tokens.json)로 유지 → 단일 진실 공급원 확보.
  • 플랫폼(css/js/…)을 config에 선언만 하면 각 포맷으로 자동 생성.
  • Tokens Studio 확장(@tokens-studio/sd-transforms)이 Figma가 뱉는 토큰 문법(별칭 참조, $type/$value)을 그대로 먹어준다.

우리 토큰이 Figma Tokens Studio 출신이라, 이 조합이 사실상 정답이었다. 그래서 초기 세팅 커밋에서 Style Dictionary 5.4.0을 깔고 config를 붙였다.

토큰 계층: primitive → semantic

파이프라인을 붙이기 전에, 토큰 자체가 계층으로 정리돼 있어야 의미가 있다. tokens.json을 열어보면 토큰 셋이 이렇게 나뉜다.

Primitive Color/Mode 1   ← 팔레트 원자값 (Red, Green, Neutral Grey, ...)
Semantic Color/Mode 1    ← 역할 (Action, Text, BG, Border, Icon, ...)
Font / Spacing / Border Radius / Layout ...

핵심은 semantic이 primitive를 직접 참조한다는 점이다. 값을 두 번 쓰지 않는다.

// Primitive: 순수 팔레트. "이 색이 무슨 색인가"만 안다.
"Primitive Color/Mode 1": {
  "Red": {
    "600": { "$type": "color", "$value": "#f34a4a" },
    "700": { "$type": "color", "$value": "#dd4343" }
  }
}

// Semantic: "이 색이 무슨 역할인가". primitive를 별칭으로 가리킨다.
"Semantic Color/Mode 1": {
  "Action": {
    "primary":   { "$type": "color", "$value": "{Red.600}" },
    "secondary": { "$type": "color", "$value": "{Violet.300}" }
  },
  "Text": {
    "primary":   { "$type": "color", "$value": "{Black}" },
    "secondary": { "$type": "color", "$value": "{Cool Grey.700}" }
  }
}

왜 이렇게 나누냐면:

  • 컴포넌트는 Red.600이 아니라 Action.primary를 쓴다. 나중에 브랜드 컬러가 Red.600에서 다른 값으로 바뀌어도 semantic 이름은 그대로, primitive 매핑만 갈아끼우면 전 화면이 따라온다.
  • 다크모드/테마 확장도 이 구조라야 가능하다. semantic 레이어가 모드별 primitive를 가리키게 하면 된다.

primitive는 “무엇인지”, semantic은 “무엇을 위한 것인지”. 컴포넌트가 의존해야 하는 건 언제나 semantic 쪽이다.

Style Dictionary config 붙이기

sd.config.js. 소스는 토큰 하나, 출력은 web용 CSS 변수와 JS 상수 두 갈래. Tokens Studio 프리프로세서를 물려서 별칭({Red.600})을 자동 해석하게 했다.

// sd.config.js
import { register } from "@tokens-studio/sd-transforms";
import StyleDictionary from "style-dictionary";

// Figma Tokens Studio 문법($value 별칭 등)을 SD가 이해하도록 등록
register(StyleDictionary, {
  excludeParentKeys: true,
});

const sd = new StyleDictionary({
  source: ["tokens/tokens.json"],
  preprocessors: ["tokens-studio"],
  platforms: {
    // 1) 웹: CSS custom properties
    css: {
      transformGroup: "tokens-studio",
      prefix: "ds",              // --ds* 로 네임스페이스
      buildPath: "build/",
      files: [
        { destination: "variables.css", format: "css/variables" },
      ],
    },
    // 2) 웹/RN: JS ES6 상수
    js: {
      transformGroup: "tokens-studio",
      buildPath: "build/",
      files: [
        { destination: "vars.js", format: "javascript/es6" },
      ],
    },
  },
});

sd.buildAllPlatforms();

package.json은 이 한 줄이 핵심이다.

{
  "name": "design-system",
  "type": "module",
  "scripts": {
    "build": "node sd.config.js"   // ← 드디어 '빌드'가 생겼다
  },
  "dependencies": {
    "@tokens-studio/sd-transforms": "^2.0.3",
    "style-dictionary": "^5.4.0"
  }
}

yarn build 한 방이면 build/에 산출물이 떨어진다. semantic이 primitive를 참조하던 별칭도 최종값으로 풀려서 나온다.

/* build/variables.css — 자동 생성물 */
:root {
  --dsRed600: #f34a4a;
  --dsRed700: #dd4343;
  /* ... */
  --dsFontSizesLabel14: 14px;
  --dsFontSizesLabel18: 18px;
}
/* build/vars.js — 자동 생성물 */
export const red600 = "#f34a4a";
export const red700 = "#dd4343";
export const fontSizesLabel14 = "14px";
export const fontSizesLabel18 = "18px";

이제 소비 레포는 이 CSS 하나를 import 하거나, JS 상수를 가져다 쓰면 끝이다. 매직 넘버 #f34a4a 대신 var(--dsRed600) 혹은 Action.primary가 가리키는 변수를 참조한다.

토큰이 “살아 움직이는지” 확인 — 타이포 tight 케이스

파이프라인이 진짜 도는지 검증하는 가장 좋은 방법은, 토큰을 실제로 한 번 바꿔보는 거다. 마침 라벨 타이포가 너무 헐렁하다는 피드백이 있어서 tight하게 조정했다.

// tokens.json — label 타이포 tight 적용
  "lineHeight": "{lineHeights.13}",
- "letterSpacing": "{letterSpacing.2}",
+ "letterSpacing": "{letterSpacing.1}",
  ...
- "lineHeight": "{lineHeights.13}",
+ "lineHeight": "{lineHeights.16}",   // 14B → lineHeight 18

원본 토큰 몇 줄만 고치고 yarn build를 돌렸더니, CSS 변수와 JS 상수가 같이 갱신됐다. 한 곳(토큰)만 고치면 모든 산출물이 따라온다 — 이게 파이프라인이 살아있다는 증거다. 예전 같으면 레포 6개의 라벨 스타일을 손으로 찾아 고쳤을 일이다.

그래서 0% → 1%

정리하면 이번 편에서 한 일은 이거다.

  1. 흩어진 구현·난립한 스타일링의 원인 = 단일 진실 공급원 부재로 진단.
  2. 이미 있던 고아 design-system 레포에 Style Dictionary config를 붙여 실제 빌드 파이프라인화.
  3. 토큰을 primitive → semantic 계층으로 정돈, semantic이 primitive를 별칭 참조하게.
  4. tokens.json → CSS 변수/JS 상수 산출물 생성 → 배포 가능한 형태 확보.
  5. 타이포 tight 수정으로 “토큰 한 번 고치면 전부 반영”을 실증.

채택률로는 여전히 1%다. 근데 이제 소비할 물건이 존재한다. 다음 편에서는 이 산출물을 실제 컴포넌트(vanilla-extract 테마 바인딩, DCButton variant 시스템)와 모노레포로 어떻게 연결했는지 이어서 쓴다.

큰 통합은 항상 “쓸 수 있는 표준 1개”에서 시작한다. 0에서 1을 만드는 게 1에서 100보다 어렵다.

관련 작업

  • 초기 세팅 (2026-04-15) — Style Dictionary 5.4.0 + @tokens-studio/sd-transforms 도입, sd.config.js/build 스크립트 신설, CSS·JS 산출물 파이프라인 구축.
  • tokens/border 정리 (PR #4, #5), typography 정리 (PR #6) — 토큰 계층·border/typography 정돈.
  • label 타이포 tight 적용 (14B lineHeight 18, label letterSpacing -0.5px) (2026-08-04) — 토큰 수정 → 산출물 자동 반영 검증.