🧩 Design System7. Panda × Tailwind 호환Coexistence Patterns — 라우트별·계층별·한 JSX 내부의 3가지 공존 패턴

Coexistence Patterns

이 문서가 답하는 질문: 두 도구를 어디서 같이 쓸 것인가. 라우트 단위인가, 컴포넌트 계층 단위인가, 한 JSX 내부인가. 한 줄 답 (Pyramid Top): 공존은 “경계의 명시성” 의 함수다. 패턴 A(Route-based)는 URL 경계, 패턴 B(Layer-based)는 패키지 경계, 패턴 C(In-JSX)는 경계 없음 — 위험도와 자유도가 정비례한다. 신규 마이그레이션은 A부터, DS 패키지가 분리되어 있으면 B, 피치 못한 경우만 C.


Why — 왜 패턴이 필요한가

두 도구의 공존이 가능해도, 어디서 어떤 도구를 쓰는가에 대한 약속이 없으면 결국 “이 컴포넌트는 왜 Tailwind인데 저건 Panda인가?” 가 PR마다 반복된다. 답이 “그때그때 다르다” 면, 일관성은 영원히 도달 못 한다.

풀려는 문제패턴 없는 경우패턴 있는 경우
”어디서 어떤 도구를 쓰나”PR마다 협상규칙 1줄로 결정
신규 멤버 온보딩”둘 다 쓰는데, 음…""/legacy는 TW, /new는 Panda”
리팩토링 영향 반경전체 코드베이스한 패턴에 닫힘
빌드 시간 폭증모두 두 도구 스캔패턴별 분리 가능
마이그레이션 진척률 측정불가능”Tailwind 파일 수 / 전체”로 측정

How — 세 패턴의 의사결정 트리


What — 패턴 A: Route-based Separation

구조

app/
  (legacy)/                  ← Tailwind 전용
    dashboard/page.tsx
    settings/page.tsx
    layout.tsx               ← Tailwind preflight만
  (new)/                     ← Panda 전용
    pricing/page.tsx
    onboarding/page.tsx
    layout.tsx               ← Panda preset.reset만
  globals.css                ← tokens.css만 import (양쪽 공통)

Next.js의 Route Group((legacy))으로 layout 단위를 나눈다.

코드 — 레이아웃 분리

// app/(legacy)/layout.tsx
import './tailwind.css'         // Tailwind preflight + utilities
 
export default function LegacyLayout({ children }: { children: React.ReactNode }) {
  return <div className="font-sans antialiased">{children}</div>
}
 
// app/(new)/layout.tsx
import './panda.css'            // Panda preset.reset + recipes + utilities
 
export default function NewLayout({ children }: { children: React.ReactNode }) {
  return <div>{children}</div>
}
 
// app/layout.tsx (root)
import './globals.css'          // tokens.css 한 줄만
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>{children}</body>
    </html>
  )
}

globals.css:

@import './tokens.css';        /* CSS variables (SSOT, 양쪽 공통) */

tailwind.css (Tailwind v4):

@import 'tailwindcss';

panda.css:

@layer reset, base, tokens, recipes, utilities;

(Panda가 자동으로 styled-system/styles.css를 채움)

장단점

측면평가
안전도⭐⭐⭐⭐⭐ 가장 안전 — 두 도구가 다른 페이지에서만 산다
빌드 시간⭐⭐⭐⭐ 페이지별 CSS 분리 → tree-shaking 효과
자유도⭐⭐ 같은 컴포넌트를 두 페이지에서 쓰려면 두 번 만들기
마이그레이션 적합도⭐⭐⭐⭐⭐ 신규 라우트를 새 도구로, 레거시는 그대로

가장 흔한 케이스: 레거시 SaaS가 Tailwind로 5년 굴려왔고, 신규 checkout 페이지만 Panda로 시도. /checkout이 안정화되면 다음 라우트도 Panda로 옮긴다.

함정

  • 공유 컴포넌트 (예: <Button>)를 어디 둘 것인가? 답: 두 패턴 둘 다에서 import 가능하게 만들려면 패턴 B로 끌어올려야 함. 그 시점에 Route-only 분리는 순수성을 잃음.
  • 헤더·푸터처럼 모든 페이지에 공통인 요소: 보통 root layout에 두지만, 그러면 어느 도구 쪽인지 결정 필요. 권장: 둘 다 동작하는 CSS variables 기반 utility로 작성 (예: 그냥 style={{ background: 'var(--colors-bg-surface)' }}).

What — 패턴 B: Layer-based Separation

구조

packages/
  ui/                        ← Panda 전용 (디자인 시스템 패키지)
    src/
      Button.tsx             ← cva, slot recipe
      Input.tsx
      panda.config.ts
  app/                       ← Tailwind 전용 (앱)
    app/
      page.tsx               ← <Button />를 import + Tailwind utility로 레이아웃
      layout.tsx
    tailwind.config.ts

핵심 원칙: 컴포넌트 자체는 Panda recipe로, 컴포넌트를 배치하는 페이지 레이아웃은 Tailwind utility로.

코드 — DS 패키지 (Panda)

// packages/ui/src/Button.tsx
import { cva } from 'styled-system/css'
 
const button = cva({
  base: {
    display: 'inline-flex',
    alignItems: 'center',
    justifyContent: 'center',
    px: 4, py: 2,
    rounded: 'md',
    fontWeight: 'medium',
    cursor: 'pointer',
    _disabled: { opacity: 0.5, cursor: 'not-allowed' },
  },
  variants: {
    intent: {
      primary: { bg: 'primary.500', color: 'white', _hover: { bg: 'primary.600' } },
      ghost:   { bg: 'transparent', color: 'primary.500', _hover: { bg: 'primary.50' } },
    },
    size: {
      sm: { fontSize: 'sm', px: 3, py: 1 },
      md: { fontSize: 'base' },
      lg: { fontSize: 'lg', px: 5, py: 3 },
    },
  },
  defaultVariants: { intent: 'primary', size: 'md' },
})
 
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  intent?: 'primary' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
}
 
export function Button({ intent, size, className, ...rest }: Props) {
  return <button className={[button({ intent, size }), className].filter(Boolean).join(' ')} {...rest} />
}

DS 패키지는 Panda codegen 결과(styled-system/)를 함께 배포해야 한다 — 또는 런타임 의존성으로 styled-system을 두는 대신 빌드 시점에 CSS를 추출해 함께 export.

packages/ui/package.json:

{
  "name": "@app/ui",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./styles.css": "./dist/styles.css"
  },
  "scripts": {
    "build": "panda codegen && tsup src/index.ts --format esm --dts && panda cssgen --outfile dist/styles.css"
  }
}

코드 — 앱 (Tailwind)

// apps/app/app/page.tsx
import { Button } from '@app/ui'
 
export default function HomePage() {
  return (
    <div className="min-h-screen bg-bg-surface text-text-default p-8 grid grid-cols-12 gap-4">
      <header className="col-span-12 flex items-center justify-between">
        <h1 className="text-3xl font-bold">대시보드</h1>
        <Button intent="primary">새로 만들기</Button>
      </header>
 
      <main className="col-span-9">
        {/* Tailwind utility로 레이아웃 */}
        <div className="grid grid-cols-3 gap-4">
          <Card>...</Card>
          <Card>...</Card>
        </div>
      </main>
 
      <aside className="col-span-3 sticky top-4">
        <Button intent="ghost" size="sm">설정</Button>
      </aside>
    </div>
  )
}

앱 루트에서 두 CSS를 import:

// apps/app/app/layout.tsx
import '@app/ui/styles.css'    // Panda recipes (DS 패키지에서)
import './globals.css'         // tokens.css + Tailwind utilities

globals.css:

@import './tokens.css';
@import 'tailwindcss';

장단점

측면평가
안전도⭐⭐⭐⭐ DS 패키지의 내부는 외부에서 안 건드림
빌드 시간⭐⭐⭐⭐ DS는 별도 build, 앱은 Tailwind만
자유도⭐⭐⭐⭐ 앱은 utility 자유, DS는 type-safe recipe
마이그레이션 적합도⭐⭐⭐ DS만 Panda로 옮기는 중간 마이그레이션 단계로도 적합
조직 적합도⭐⭐⭐⭐⭐ DS 팀 ↔ 앱 팀이 분리된 조직에 천연 적합

함정

  • DS 패키지의 토큰과 앱의 토큰이 어긋남 — 둘 다 *같은 tokens.css*를 런타임에 보게 만들어야 한다. DS 패키지가 자기 토큰을 export하면 사고. 권장: DS 패키지는 토큰 정의 없이 var(...)만 참조.
  • DS 패키지가 자기 reset을 emitpreflight: false로 끄고, reset은 앱 쪽의 Tailwind preflight에 위임.
  • Tailwind purge가 DS 패키지의 클래스를 삭제tailwind.config.content'node_modules/@app/ui/dist/**/*.{js,css}' 추가. 또는 DS 패키지의 CSS를 그대로 import하고 Tailwind는 무시(권장).

What — 패턴 C: In-JSX Mix

구조 (가장 위험)

import { css } from 'styled-system/css'
 
export function CardC() {
  return (
    <div className={`p-4 rounded-lg shadow-md ${css({ bg: 'bg.surface', color: 'text.default' })}`}>
      {/* Tailwind utility + Panda css 혼용 */}
      <h2 className="text-2xl font-bold mb-2">
        혼용 카드
      </h2>
      <p className={css({ fontSize: 'sm', color: 'gray.500' })}>
        본문
      </p>
      <button
        className={`mt-4 ${css({
          bg: 'primary.500',
          color: 'white',
          px: 4, py: 2,
          rounded: 'md',
        })}`}
      >
        클릭
      </button>
    </div>
  )
}

언제 이 패턴이 정당화되는가

케이스정당성
레이아웃은 Tailwind가 표현력 좋음, 색·상태는 Panda recipe한정적으로 OK
마이그레이션 중 한 컴포넌트만 임시로 양쪽1주 이내 끝나면 OK
그 외 모든 경우피할 것

장단점

측면평가
안전도⭐ 가장 위험 — specificity 다툼·purge 충돌·인지 부하
빌드 시간⭐⭐ 매 파일에서 두 도구 모두 분석
자유도⭐⭐⭐⭐⭐ 무한
마이그레이션 적합도⭐⭐⭐ 단기간만
인지 부하매우 높음 — 같은 색을 두 가지로 쓸 수 있음

함정 (이 패턴의 본질)

  • 함정 1: className="px-4 ${css({ px: 8 })}"같은 속성을 둘 다 지정. 어느 쪽이 이길지는 CSS 출력 순서에 의존 → cascade 다툼.
  • 함정 2: 팀이 “여기는 Tailwind, 저기는 Panda”의 기준을 잃음 → 일관성 파괴.
  • 함정 3: 같은 화면을 두 사람이 만들면 한 명은 Tailwind, 한 명은 Panda. PR 리뷰가 스타일 선택에 시간 낭비.

What — 세 패턴 비교 매트릭스

A: RouteB: LayerC: In-JSX
경계URL패키지없음
빌드 시간페이지별 분리 가능DS 별도 빌드둘 다 모든 파일 스캔
공유 컴포넌트어려움자연스러움 (DS 패키지)가능
specificity 다툼거의 없음있음(가장자리)자주
인지 부하낮음중간높음
마이그레이션 적합⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐ (단기만)
장기 유지가능 (영구 공존도 OK)가능 (DS-앱 분리는 본질적)부적합

What-if — 패턴이 무너지는 경우

  • 함정 1: A에서 시작했는데 공유 컴포넌트가 늘어남 → 결국 B로 진화. 미리 DS 패키지를 분리해 두면 자연스러운 승격 가능.
  • 함정 2: B에서 앱 쪽도 Panda recipe를 쓰고 싶어짐 → DS 패키지에 recipe 정의를 export하거나, 앱도 Panda 도입(이 시점에 Tailwind 제거 검토).
  • 함정 3: C에서 시작한 프로젝트가 1년 흐름 → 인지 부하가 누적되어 신규 PR이 모두 선택 paralysis에 빠짐. 권장: 분기마다 어느 한쪽으로 통일 진척률 측정 + 90% 도달 시 나머지 일괄 마이그레이션.
  • 함정 4: 세 패턴을 동시에 운용 — 같은 코드베이스에 A, B, C가 다 있음. 신규 멤버 멘붕. 권장: 한 시점에 한 패턴만 active.

Insight — 왜 경계가 모든 것을 결정하는가

소프트웨어 아키텍처의 거의 모든 문제는 “경계가 어디 있는가” 로 환원된다. 마이크로서비스 vs 모놀리식, 클라이언트 vs 서버, Pure function vs Side effect — 모두 경계 문제.

Panda + Tailwind 공존도 같다. 경계가 URL이면 안전, 패키지면 적당, 없음이면 위험.

흥미로운 통찰: shadcn/ui는 본질적으로 패턴 B의 한 변형이다. shadcn은 자신을 컴포넌트 라이브러리로 부르지 않고 복사-붙여넣기 코드로 부르는데, 이것이 “DS 패키지를 앱 내부에 fork해서 두는” 변형이다. npm 의존성으로서의 DS가 아니라 내 코드로서의 DS. 이 모델에서는 DS의 도구(Tailwind)와 앱의 도구(Tailwind)가 일치하기에 충돌이 없다.

시사점: 패턴 B의 어려움(DS와 앱이 다른 도구)을 피하려면, shadcn처럼 같은 도구를 쓰되 컴포넌트를 fork하는 방향도 있다. Panda + Tailwind 공존을 시작하기 전에, shadcn 방식으로 Tailwind 단독 + 복붙 컴포넌트가 충분치 않은지 먼저 검토하는 것이 옳다.

또 하나의 흥미로운 관찰: 패턴 A는 “공존”이 아니라 “두 앱이 같은 도메인 아래” 에 가깝다. 진정한 의미의 공존은 패턴 B와 C뿐이며, B는 조직적 분리기술적 분리를 자연히 만든 경우, C는 기술적 강제 결합. 즉 진짜 공존은 패턴 B 하나다.


요약

  • 패턴 A (Route-based): URL 단위 분리, 가장 안전, 마이그레이션 진입 추천.
  • 패턴 B (Layer-based): DS 패키지 = Panda, 앱 = Tailwind. 조직 구조와 정렬되면 최적.
  • 패턴 C (In-JSX): 가능하지만 단기간만. 1년 넘어가면 통일로 옮길 것.
  • 세 패턴은 경계의 명시성 축에서 진열되며, 명시적일수록 안전.