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 utilitiesglobals.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을 emit —
preflight: 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: Route | B: Layer | C: 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년 넘어가면 통일로 옮길 것.
- 세 패턴은 경계의 명시성 축에서 진열되며, 명시적일수록 안전.