🧩 Design System8. Pipeline & Distribution04 — 토큰 이름 변경 deprecation: alias·warn·codemod

04 — 토큰 이름 변경 deprecation: alias·warn·codemod

이 문서가 답하는 질문: color.brandcolor.primary로 바꾸려고 하는데, 그 이름을 200곳에서 쓰고 있다면 어떻게 깨뜨리지 않고 옮기는가. 한 줄 답 (Pyramid Top): 토큰 이름 변경은 4-스텝 deprecation 사이클(1) 새 이름 추가 + 기존을 alias로 유지 (2) 기존 이름 사용 시 console.warn (3) codemod 동봉 (4) 다음 메이저에서 기존 이름 제거 — 으로 사용자를 최소 하나의 메이저 버전 동안 양립시킨다.


Why — 토큰 이름은 공개 API

디자인 시스템에서 가장 비싼 변경은 컴포넌트 prop이 아니라 토큰 이름이다.

토큰 이름의 특성함의
Tailwind class에 직접 박힘 (bg-brand-500)grep 가능하지만 수백 곳
Panda recipe에 직접 박힘 (bg: 'brand.500')타입 안전 → 깨지면 컴파일 에러
CSS 변수에 박힘 (var(--color-brand))동적 — 런타임에 조용히 undefined
Figma 변수에 박힘디자이너 작업물도 같이 마이그레이션 필요

토큰 이름은 공개 API의 일부다 — 변경은 메이저 버전에서만 허용되며, 그 메이저에 도달하기 전 충분한 deprecation이 필요하다.

풀려는 문제이전 해법한계
토큰 rename메이저 bump + breaking note사용자가 200곳을 수동 수정
”어디서 쓰는지 모름”grepPanda recipe·CSS var·Figma는 grep 어려움
”디자이너도 같이 옮겨야”슬랙 공지누락·시점 어긋남
점진적 마이그레이션4-스텝 deprecation 사이클

How — 4-스텝 deprecation 사이클

각 단계가 최소 1개 minor 버전에 머문다 — 사용자에게 옮길 시간을 주는 것.


What — 각 단계의 구체 코드

Step 1: 새 이름 + alias

packages/tokens/src/semantic/color.json:

{
  "color": {
    "primary": {
      "$value": "{color.blue.500}",
      "$type": "color",
      "$description": "Primary action color (Buttons, links, focus rings)"
    },
    "brand": {
      "$value": "{color.primary}",
      "$type": "color",
      "$description": "@deprecated Use color.primary instead. Will be removed in v2.0.",
      "$extensions": {
        "com.org.deprecated": {
          "since": "1.5.0",
          "replacement": "color.primary",
          "removeIn": "2.0.0"
        }
      }
    }
  }
}

DTCG $extensions에 deprecation 메타를 박는다 — Style Dictionary가 이를 읽어 자동 처리할 수 있다.

Step 2: console.warn

Panda preset에서 deprecated 토큰 사용 시 dev mode에서 경고:

// packages/panda-preset/src/index.ts
import { definePreset } from '@pandacss/dev';
import tokens from '@org/tokens/panda';
import deprecated from '@org/tokens/deprecated.json';
 
export default definePreset({
  name: '@org/panda-preset',
  theme: {
    extend: {
      tokens: tokens.tokens,
      semanticTokens: {
        colors: {
          ...tokens.semanticTokens.colors,
          // Deprecated alias — warn on read
          ...(process.env.NODE_ENV !== 'production'
            ? wrapDeprecated(deprecated)
            : deprecated),
        },
      },
    },
  },
});
 
function wrapDeprecated(map: Record<string, any>) {
  const out: Record<string, any> = {};
  for (const [name, value] of Object.entries(map)) {
    out[name] = {
      ...value,
      // Panda extension that hooks read
      $extensions: {
        'com.org.warn': () =>
          console.warn(
            `[@org/tokens] color.${name} is deprecated. ` +
            `Use color.${value.$extensions['com.org.deprecated'].replacement} instead. ` +
            `Will be removed in ${value.$extensions['com.org.deprecated'].removeIn}.`
          ),
      },
    };
  }
  return out;
}

Tailwind preset 측은 빌드 시점에 경고:

// packages/tailwind-preset/src/index.ts
import type { Config } from 'tailwindcss';
import tokens from '@org/tokens/tailwind';
 
const deprecatedColors = new Set(['brand']);
 
export default {
  theme: {
    extend: {
      colors: new Proxy(tokens.colors, {
        get(target, prop: string) {
          if (deprecatedColors.has(prop) && process.env.NODE_ENV !== 'production') {
            console.warn(
              `[@org/tailwind-preset] colors.${prop} is deprecated. ` +
              `Use colors.primary instead.`
            );
          }
          return target[prop];
        },
      }),
    },
  },
} satisfies Partial<Config>;

ESLint 규칙으로 컴파일 타임 에러도 가능:

// .eslintrc.cjs (사용자 측이 깔 수 있음)
module.exports = {
  rules: {
    'no-restricted-syntax': [
      'warn',
      {
        selector: 'Literal[value=/^(bg|text|border)-brand-/]',
        message: 'colors.brand is deprecated. Use colors.primary.',
      },
    ],
  },
};

Step 3: codemod 동봉

packages/codemods/src/v2/rename-brand-to-primary.ts (jscodeshift):

import type { Transform } from 'jscodeshift';
 
const RENAME_MAP: Record<string, string> = {
  'color.brand': 'color.primary',
  'colors.brand': 'colors.primary',
  '--color-brand': '--color-primary',
};
 
const TAILWIND_CLASS_MAP: Record<string, string> = {
  'bg-brand': 'bg-primary',
  'text-brand': 'text-primary',
  'border-brand': 'border-primary',
};
 
const transform: Transform = (file, api) => {
  const j = api.jscodeshift;
  const root = j(file.source);
 
  // 1) String literals — Panda recipe, CSS-in-JS
  root.find(j.Literal).forEach((path) => {
    const value = path.node.value;
    if (typeof value !== 'string') return;
 
    // "color.brand" 같은 토큰 reference
    for (const [from, to] of Object.entries(RENAME_MAP)) {
      if (value === from || value.includes(from)) {
        path.node.value = value.replaceAll(from, to);
      }
    }
 
    // Tailwind class — "bg-brand-500" → "bg-primary-500"
    for (const [from, to] of Object.entries(TAILWIND_CLASS_MAP)) {
      const regex = new RegExp(`\\b${from}-(\\d+)\\b`, 'g');
      path.node.value = (value as string).replace(
        regex,
        (_, num) => `${to}-${num}`,
      );
    }
  });
 
  // 2) Template literals — `bg-brand-${shade}`
  root.find(j.TemplateLiteral).forEach((path) => {
    path.node.quasis.forEach((q) => {
      for (const [from, to] of Object.entries(TAILWIND_CLASS_MAP)) {
        q.value.raw = q.value.raw.replaceAll(from, to);
        q.value.cooked = q.value.cooked?.replaceAll(from, to);
      }
    });
  });
 
  return root.toSource({ quote: 'single' });
};
 
export default transform;

사용자가 실행:

# 한 번에 마이그레이션
pnpm dlx @org/codemods v2/rename-brand-to-primary "src/**/*.{ts,tsx,css}"

packages/codemods/bin.ts (CLI):

#!/usr/bin/env node
import { run as jscodeshift } from 'jscodeshift/src/Runner';
import { resolve } from 'node:path';
 
const [, , transformName, ...paths] = process.argv;
const transformPath = resolve(
  __dirname,
  `transforms/${transformName}.js`,
);
 
jscodeshift(transformPath, paths, {
  parser: 'tsx',
  extensions: 'ts,tsx,js,jsx,css',
  verbose: 1,
});

package.json:

{
  "name": "@org/codemods",
  "bin": "./dist/bin.js",
  "scripts": {
    "test": "vitest"
  }
}

각 transform에는 입력·출력 예시 fixturevitest 테스트가 동봉된다:

packages/codemods/
└── src/v2/rename-brand-to-primary/
    ├── transform.ts
    ├── transform.test.ts
    └── __fixtures__/
        ├── input.tsx
        └── output.tsx

Step 4: 메이저에서 제거

v2.0 publish 시 changeset:

---
"@org/tokens": major
"@org/react": major
---
 
**BREAKING**: `color.brand`를 제거합니다.
 
v1.5에서 deprecated 되었습니다. 마이그레이션:
 
```bash
pnpm dlx @org/codemods v2/rename-brand-to-primary "src/**/*.{ts,tsx,css}"

수동으로 옮기려면:

  • color.brand.*color.primary.*
  • bg-brand-*bg-primary-*
  • --color-brand-*--color-primary-*

---

## Figma 측 마이그레이션

토큰은 *디자인 도구에도* 살아 있다. Tokens Studio 또는 Figma Variables의 이름도 같이 옮겨야 한다.

```mermaid
sequenceDiagram
    participant FE as Figma 디자이너
    participant TS as Tokens Studio
    participant GH as GitHub (tokens)
    participant SD as Style Dictionary
    participant USER as 사용자 앱

    FE->>TS: brand → primary로 rename
    TS->>GH: push to tokens.json
    GH->>SD: 빌드 트리거
    SD->>GH: dist/ 업데이트 (alias 포함)
    GH->>USER: npm publish v1.5
    Note over USER: alias 유지 → 사용자 코드 안 깨짐

핵심: Tokens Studio에서 이름 변경이 아니라 새 이름 추가 + 기존 alias 작업한다. Figma 측에서도 1.5는 두 이름 모두 살아 있음.


What-if — 잘못 쓰면 어떻게 깨지는가

  • 함정 1 — alias 누락: 새 이름만 추가하고 기존을 안 두면 즉시 깨짐. 대응: deprecation PR 템플릿에 체크리스트[ ] 기존 이름이 alias로 살아 있는가.

  • 함정 2 — warn이 prod에 노출: process.env.NODE_ENV 체크 누락 → 사용자 prod console 오염. 대응: warn은 반드시 NODE_ENV !== 'production' 가드. CI에서 빌드 후 grep으로 검증.

  • 함정 3 — codemod가 문자열만 옮김: <div className={isActive ? 'bg-brand' : 'bg-gray'}>처럼 조건부 표현식은 잘 잡지만, clsx({'bg-brand-500': isActive}) 같은 객체 키는 jscodeshift가 놓치기 쉬움. 대응: fixture에 모든 사용 패턴을 미리 등록 + 테스트. 그래도 missed가 있으니 ESLint 규칙을 함께 동봉.

  • 함정 4 — Tailwind safelist에 옛 이름: 사용자가 tailwind.config.tssafelistbg-brand-500을 박아뒀음 → codemod가 동적 클래스 생성 코드를 못 찾음. 대응: safelist 별도 grep 가이드를 release notes에 명시.

  • 함정 5 — Figma·코드 시점 어긋남: 디자이너는 새 이름으로 작업 중인데 사용자 앱은 아직 v1.5 미만 → Figma export가 알 수 없는 토큰. 대응: Figma 측에도 동일한 deprecation 사이클 (Tokens Studio의 alias 기능 활용). 디자이너와 동시 릴리스가 원칙.

  • 함정 6 — 메이저에서 제거 후 사용자가 v1으로 회귀 못 함: v1 → v2 마이그레이션 도중 v2에 새 버그 발견. 그런데 v1에선 새 컴포넌트가 없어서 못 돌아감. 대응: v1을 최소 6개월 maintenance (security/critical patch만). v2는 완벽히 검증된 시점에만 v1을 EOL.


Insight — React의 componentWillMount 사례

토큰 deprecation 사이클은 React가 lifecycle method를 처리한 방식의 디자인 시스템 버전이다.

React 16.3 (2018):

  1. componentWillMountUNSAFE_componentWillMount rename
  2. 기존 이름은 console.warn과 함께 살려둠
  3. codemod 동봉 (react-codemod rename-unsafe-lifecycles)
  4. React 17에서 기존 이름 제거

같은 사이클 — 새 이름 + warn + codemod + 메이저 제거.

반전: React 18은 결국 기존 lifecycle을 그대로 둠. Concurrent rendering이 추가됐을 뿐. 이유는 수많은 레거시 코드의 마이그레이션 비용. Deprecation은 제거의 보장이 아니라 이동의 권장이다 — 사용자가 충분히 옮기지 않으면 언제든 연장될 수 있다.

디자인 시스템도 같은 교훈: Deprecation 발표는 자유, 제거는 점유율 95% 이하일 때.


요약

  • 토큰 이름 변경은 alias → warn → codemod → 메이저 제거의 4-스텝 사이클.
  • DTCG $extensions에 deprecation 메타를 박아 기계가 추적하게 한다.
  • codemod는 문자열·템플릿·objects keys를 모두 다뤄야 하며, fixture·test가 동봉되어야 한다.
  • Figma 측 동기화는 Tokens Studio alias로 같은 사이클을 만든다.
  • 메이저 제거는 사용자 점유율 95% 이하일 때만 — 깰 자유는 있지만 제거의 의무는 없다.