🧩 Design System7. Panda × Tailwind 호환Migrating Tailwind to Panda — jscodeshift codemod와 점진적 변환

Migrating Tailwind to Panda

이 문서가 답하는 질문: 5년간 Tailwind로 작성된 50만 줄 코드베이스를 서비스 중단 없이 Panda CSS로 옮기려면 어떻게 해야 하는가. 한 줄 답 (Pyramid Top): 마이그레이션은 “4-phase: 토큰 통일 → codemod 도구 준비 → 라우트별 변환 → Tailwind 제거” 다. 가장 위험한 구간은 codemod 작성이 아니라 토큰 매핑. 색·spacing의 의미적 동치가 확정되지 않으면 어떤 codemod도 의미 없는 클래스 교체에 그친다.


Why — 왜 마이그레이션을 고려하는가

5~10년차 Tailwind 코드베이스에서 흔히 발견되는 피로 신호:

신호원인Panda가 해결하는 방식
className이 길어져 한 줄에 안 맞음 (px-4 py-2 ... × 15개)utility는 변형 폭증에 약함recipe + variant로 의미 박제
cva 또는 tv 위에 또 wrapper 만듦variant가 1급이 아님Panda cva 1급, sva, compound variants
clsx/classnames로 조건부 분기 폭발conditional이 string 합성_hover, _disabled 같은 condition을 객체 키로
typo로 missing class가 production에 흐름string 기반 → 타입 체크 없음TS codegen으로 컴파일 타임 잡음
다크모드가 dark: prefix × 100개토큰 차원이 일관되지 않음semantic token + _dark condition
디자이너가 --color-primary-500 색을 바꾸면 어디에 영향 가는지 모름grep 외에는 추적 불가Panda token reference graph

그러나 — 마이그레이션은 비용이 크다. Tailwind의 한계 ≠ Panda의 필연. 다음 08번 문서에서 마이그레이션하지 말아야 할 때도 다룬다. 이 문서는 결정이 이미 내려진 경우어떻게를 다룬다.


How — 4-phase 마이그레이션 전략

Phase 1: 토큰 통일 (2~4주)

가장 결정적인 단계. 코드 변환 전에 토큰의 의미적 동치를 확정해야 한다.

// migration/token-map.ts — 손으로 작성하는 매핑표
export const tokenMap = {
  // 색
  'bg-blue-500':         'primary.500',
  'bg-blue-600':         'primary.600',
  'bg-gray-50':          'bg.surface',
  'text-gray-900':       'text.default',
  'text-gray-500':       'text.muted',
 
  // 다크모드 ─ semantic token이 자동 처리하므로 dark: 변형 제거
  'dark:bg-blue-500':    null,           // → primary.500이 dark에서 자동 변경
  'dark:bg-gray-900':    null,           // → bg.surface가 자동
  'dark:text-gray-50':   null,
 
  // Spacing (그대로)
  'px-4':                { px: 4 },
  'py-2':                { py: 2 },
  'p-4':                 { p: 4 },
 
  // Radius
  'rounded-md':          { rounded: 'md' },
  'rounded-lg':          { rounded: 'lg' },
 
  // 의미적 불일치 — 따로 표시
  '__manual__': [
    'space-x-4',         // → flex + gap 으로 변경 권장 (수동)
    'divide-y',          // → border-bottom 패턴 (수동)
  ],
}

핵심 작업: 모든 utility 클래스의 사용 빈도를 측정하고 상위 90%를 매핑. 나머지 10%는 __manual__로 분류해 수동 변환 표시.

빈도 측정 스크립트:

# 모든 className 추출 (간단 버전)
grep -rhoE 'className=["\047][^"\047]+' app/ \
  | tr -d 'className="' \
  | tr ' ' '\n' \
  | sort | uniq -c | sort -rn > class-usage.txt

Phase 2: codemod 도구 준비 (1~2주)

jscodeshiftAST 기반 변환. 정규식보다 안전.

// migration/transform.cjs
const { tokenMap } = require('./token-map')
 
module.exports = function transformer(file, api) {
  const j = api.jscodeshift
  const root = j(file.source)
 
  // 1) className 속성 찾기
  root
    .find(j.JSXAttribute, { name: { name: 'className' } })
    .forEach(path => {
      const value = path.value.value
      // string literal: className="px-4 py-2 ..."
      if (value && value.type === 'Literal') {
        const result = transformClassString(value.value)
        if (result.unknown.length === 0) {
          // 완전 변환 가능 → css({...})로 교체
          replaceWithCssCall(j, path, result.cssObject)
        } else {
          // 일부만 변환 → className + css 혼합
          replaceWithMixed(j, path, result)
        }
      }
    })
 
  return root.toSource({ quote: 'single' })
}
 
function transformClassString(classes) {
  const cssObject = {}
  const unknown = []
  const tailwindKeep = []
 
  for (const cls of classes.split(/\s+/).filter(Boolean)) {
    const mapped = tokenMap[cls]
    if (mapped === null) {
      // skip (예: dark: variant 제거)
    } else if (mapped === undefined) {
      unknown.push(cls)
      tailwindKeep.push(cls)
    } else if (typeof mapped === 'string') {
      // 색만 매핑된 경우
      Object.assign(cssObject, parseBackgroundOrTextClass(cls, mapped))
    } else if (typeof mapped === 'object') {
      Object.assign(cssObject, mapped)
    }
  }
 
  return { cssObject, unknown, tailwindKeep }
}
 
function parseBackgroundOrTextClass(cls, token) {
  if (cls.startsWith('bg-'))   return { bg: token }
  if (cls.startsWith('text-')) return { color: token }
  if (cls.startsWith('border-')) return { borderColor: token }
  return {}
}
 
function replaceWithCssCall(j, path, cssObject) {
  const properties = Object.entries(cssObject).map(([k, v]) =>
    j.objectProperty(
      j.identifier(k),
      typeof v === 'number' ? j.numericLiteral(v) : j.stringLiteral(String(v)),
    )
  )
 
  path.value.value = j.jsxExpressionContainer(
    j.callExpression(j.identifier('css'), [j.objectExpression(properties)])
  )
}
 
function replaceWithMixed(j, path, { cssObject, tailwindKeep }) {
  // className={`${tailwindKeep.join(' ')} ${css({...})}`}
  const cssCall = j.callExpression(
    j.identifier('css'),
    [j.objectExpression(
      Object.entries(cssObject).map(([k, v]) =>
        j.objectProperty(j.identifier(k), j.stringLiteral(String(v)))
      ),
    )],
  )
 
  path.value.value = j.jsxExpressionContainer(
    j.templateLiteral(
      [
        j.templateElement({ raw: tailwindKeep.join(' ') + ' ', cooked: tailwindKeep.join(' ') + ' ' }, false),
        j.templateElement({ raw: '', cooked: '' }, true),
      ],
      [cssCall],
    ),
  )
}

css import도 자동 추가:

// (transform.cjs 이어서)
const hasCssImport = root.find(j.ImportDeclaration, { source: { value: 'styled-system/css' } }).size() > 0
if (!hasCssImport && root.find(j.CallExpression, { callee: { name: 'css' } }).size() > 0) {
  root.get().node.program.body.unshift(
    j.importDeclaration(
      [j.importSpecifier(j.identifier('css'))],
      j.literal('styled-system/css'),
    ),
  )
}

codemod 테스트 (필수)

migration/__tests__/transform.test.ts:

import { applyTransform } from 'jscodeshift/dist/testUtils'
import transformer from '../transform.cjs'
 
const cases: [string, string, string][] = [
  [
    '단순 utility',
    `<button className="bg-blue-500 text-white px-4 py-2">x</button>`,
    `import { css } from 'styled-system/css';
<button className={css({ bg: 'primary.500', color: 'white', px: 4, py: 2 })}>x</button>`,
  ],
  [
    '다크 variant 제거',
    `<div className="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-50">x</div>`,
    `import { css } from 'styled-system/css';
<div className={css({ bg: 'bg.surface', color: 'text.default' })}>x</div>`,
  ],
  [
    '미매핑 클래스는 보존',
    `<div className="bg-blue-500 some-unknown-class">x</div>`,
    `import { css } from 'styled-system/css';
<div className={\`some-unknown-class \${css({ bg: 'primary.500' })}\`}>x</div>`,
  ],
]
 
describe('tw→panda codemod', () => {
  for (const [name, input, expected] of cases) {
    it(name, () => {
      const out = applyTransform(transformer, {}, { source: input })
      expect(out.trim()).toBe(expected.trim())
    })
  }
})

규칙: codemod 적용 전에 모든 케이스가 녹색. 새 패턴은 먼저 테스트, 그 다음 codemod 수정.

Phase 3: 라우트별 변환 (수개월)

04번 문서의 *Pattern A (Route-based)*가 이 시기의 안전망. 한 번에 한 라우트만 변환.

# 변환 실행 — 한 라우트씩
npx jscodeshift -t migration/transform.cjs \
  --extensions=tsx,ts \
  app/(new)/pricing/

각 라우트 변환 후:

  1. 시각 회귀 테스트 — Playwright + screenshot diff. 변환 전/후 픽셀 동치가 골든 룰.
  2. 에러 로그 모니터링 — 운영 환경에서 30분 관찰.
  3. 다음 라우트로.

시각 회귀 테스트 (필수)

tests/visual.spec.ts:

import { test, expect } from '@playwright/test'
 
const routes = [
  '/pricing',
  '/onboarding',
  '/settings',
]
 
for (const route of routes) {
  test(`visual: ${route} (light)`, async ({ page }) => {
    await page.goto(`http://localhost:3000${route}`)
    await expect(page).toHaveScreenshot(`${route.replace(/\//g, '_')}.light.png`, { maxDiffPixelRatio: 0.01 })
  })
 
  test(`visual: ${route} (dark)`, async ({ page }) => {
    await page.emulateMedia({ colorScheme: 'dark' })
    await page.goto(`http://localhost:3000${route}`)
    await expect(page).toHaveScreenshot(`${route.replace(/\//g, '_')}.dark.png`, { maxDiffPixelRatio: 0.01 })
  })
}

기준 이미지(baseline)는 변환 전에 저장. 변환 후 diff가 0.01 이하여야 통과.

Phase 4: Tailwind 제거 (1주)

마지막 라우트가 변환되고 1~2주 안정화 후:

# 1) tailwind.config + postcss.config에서 tailwind 제거
# 2) globals.css에서 @tailwind / @import 'tailwindcss' 제거
# 3) package.json에서 tailwindcss 제거
pnpm remove tailwindcss @tailwindcss/typography @tailwindcss/forms
 
# 4) safelist·content config 정리
 
# 5) e2e 전 라우트 재실행 + 시각 회귀 통과 확인

되돌릴 수 없는 단계이므로 반드시 git tag 찍고 진행:

git tag pre-tailwind-removal

만약 운영에서 문제 발견:

git revert <tailwind-removal-commit>

으로 되돌아갈 수 있게.


What — 점진적 변환의 진척률 측정

# Tailwind className 잔존 개수
grep -rohE 'className=["\047][^"\047]*\b(bg-|text-|p-|m-|px-|py-)' app/ \
  | wc -l
 
# Panda css() 호출 개수
grep -rohE 'css\(\{' app/ | wc -l
 
# 진척률 = Panda / (Panda + Tailwind)

CI에서 Tailwind 잔존 ≤ 임계값을 강제하면 되돌이를 방지.


What-if — 마이그레이션이 위험해지는 경우

  • 함정 1: 토큰 매핑이 기계적 1:1에 머무름bg-blue-500primary.500의미적으로 다를 수 있다. bg-blue-500cancel 버튼에도 쓰였다면 primary로 옮기면 의미가 깨짐. 권장: 매핑 전 사용처 분류 (의도별 grep).
  • 함정 2: codemod가 동적 className을 못 다룸className={`bg-${color}-500`} 같은 런타임 합성. AST에서는 변수로 보임 → 변환 실패. 해결: ① 수동 변환 표시, ② safelist에 남기고 나중에 처리.
  • 함정 3: 시각 회귀 테스트가 약함 — pixel diff만 보면 간격 1px 변경은 잡지만 대비비(contrast) 변경은 못 잡는다. 권장: 접근성 자동 검사 (axe-core + Lighthouse) 병행.
  • 함정 4: 다크모드의 dark: 변형을 자동 제거했더니 수동 추가된 다크 색까지 사라짐 — 일부 컴포넌트는 semantic token으로는 표현 못 함. 매핑표의 dark:bg-blue-500 → null위험 패턴. 권장: dark variant는 컴포넌트별로 검토 후 변환.
  • 함정 5: Phase 4에서 너무 빨리 Tailwind 제거 — 라우트 한두 개에 Tailwind 미변환 잔존이 남아 production에서 무스타일 화면. 권장: grep -c “bg-blue” 같은 잔존 카운트가 0인지 체크리스트.
  • 함정 6: 마이그레이션 중 디자인 변경도 함께 — 변환 PR에 새 디자인까지 섞으면 왜 픽셀 diff가 큰지 추적 불가. 규칙: 마이그레이션 PR은 시각 동치만, 디자인 변경은 별도 PR.

Insight — 왜 대규모 마이그레이션은 항상 실패하는가

소프트웨어 역사의 마이그레이션 흑역사는 길다. AngularJS → Angular 2, Python 2 → 3, MooTools → jQuery → React. 공통점은 “한 번에 다 갈아엎으려 했다”.

성공한 마이그레이션의 공통점은 점진성 + 동시 운용. Python 2/3는 __future__ import로 같은 파일 안에서 둘 다 동작하게 만들었다. React → Next.js App Router도 Pages + App 라우터 공존을 제공한다.

이 문서의 4-phase 전략도 같은 철학이다 — 공존이 가능한 얇은 다리(CSS variables SSOT)를 먼저 깔고, 그 위에서 점진 이동.

흥미로운 반전: Tailwind 자체가 이미 마이그레이션 친화적 이다. v3 → v4 이동에서 codemod가 함께 제공되었고, config 파일 호환 + CSS-first config 도입이라는 점진 경로를 열어 두었다. 본 문서의 마이그레이션 패턴은 Tailwind 팀이 자기 도구를 진화시킨 방식과 동일한 모양이다.

또 하나: 마이그레이션 성공의 진짜 척도는 되돌릴 수 있느냐. Phase 1~3까지는 되돌리기 쉽다 (codemod의 역방향 또는 git revert). Phase 4부터는 되돌리기 어렵다 — 그래서 4를 가장 늦게, 그리고 데이터 기반(잔존 카운트 0)으로 진입한다.


요약

  • 마이그레이션은 4-phase: 토큰 통일 → codemod → 라우트별 변환 → Tailwind 제거.
  • 가장 결정적인 phase는 1 — 의미적 동치 매핑이 codemod 품질을 결정.
  • 시각 회귀 테스트 + 잔존 카운트가 되돌이 방지의 골든 룰.
  • 마이그레이션 진행 중에는 디자인 변경 금지 — 변환 PR과 디자인 PR을 분리.