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.txtPhase 2: codemod 도구 준비 (1~2주)
jscodeshift로 AST 기반 변환. 정규식보다 안전.
// 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/각 라우트 변환 후:
- 시각 회귀 테스트 — Playwright + screenshot diff. 변환 전/후 픽셀 동치가 골든 룰.
- 에러 로그 모니터링 — 운영 환경에서 30분 관찰.
- 다음 라우트로.
시각 회귀 테스트 (필수)
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-500→primary.500은 의미적으로 다를 수 있다.bg-blue-500이 cancel 버튼에도 쓰였다면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을 분리.