04 — Runtime vs Buildtime Theming
이 문서가 답하는 질문: 다크모드를 “CSS variables 런타임 swap”으로 풀 것인가, “Tailwind v3의
dark:prefix 빌드타임 클래스”로 풀 것인가? 두 접근의 비용·유연성·번들 크기 trade-off는 정확히 어떻게 다르고, Tailwind v4의@theme는 어느 진영에 속하는가? 한 줄 답 (Pyramid Top): “빌드타임 분기는 모든 모드 × 모든 클래스의 곱집합을 CSS에 박아 번들 2배 + 새 테마 추가 시 재빌드”, “런타임 분기는 CSS variables 한 세트를 selector로 swap, 새 테마는 CSS 한 블록 추가, 번들 거의 동일” — 2025년 정답은 런타임이고, Tailwind v4의@theme이 비로소 그 방향으로 갔다. v3의dark:bg-gray-900패턴은 과거 호환 모드로 봐야 한다.
Why — 왜 존재하는가
Tailwind v3가 다크모드를 도입한 2020년, 선택지는 사실상 하나였다 — 빌드타임에 dark: variant를 별도 클래스로 prefix해서 모두 컴파일.
{/* Tailwind v3 */}
<div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-white">/* 결과 — 두 벌의 클래스 */
.bg-white { background: #ffffff; }
.dark .bg-white { /* — */ }
.dark\:bg-gray-900 { /* 빌드 시점 — 이미 클래스로 박힘 */ }
.dark .dark\:bg-gray-900 { background: #18181b; }문제는 조합 폭발:
- 색상 클래스 N개 × 2(light/dark) → CSS가 사실상 2배.
- 새 테마(예: brand=jira) 추가 →
jira:bg-...모든 클래스 재컴파일, CSS 3배. - 멀티 브랜드(3 brand) × 다크모드 = 6배.
CSS variables는 같은 문제를 다르게 푼다:
.bg-surface { background: var(--color-bg-surface); }
:root { --color-bg-surface: #fff; }
[data-theme="dark"] { --color-bg-surface: #18181b; }.bg-surface 클래스는 한 번만 컴파일된다. 컨텍스트가 바뀌면 variable 값이 바뀌고, 같은 클래스가 다른 색을 보여준다. N개 컨텍스트도 클래스는 1벌.
| 풀려는 문제 | 빌드타임 접근 | 런타임 접근 |
|---|---|---|
| 모드 전환 | CSS 두 벌 빌드 | 한 벌 + selector |
| 번들 크기 | 모드 수에 비례 | 모드 수 무관 |
| 새 테마 추가 | 재빌드 필요 | CSS 한 블록 |
| 런타임 사용자 토큰 (Shopify) | 불가능 | 자연스럽게 가능 |
| 정적 분석 | 모든 클래스가 명시적 | variable 값은 dev tools에 |
How — 어떻게 동작하는가
What — 구체 사양 / 수치 / 예시
Tailwind v3 — 빌드타임 클래스
// tailwind.config.js
module.exports = {
darkMode: 'class', // .dark 클래스 안에서만 dark: variant 활성
content: ['./src/**/*.{tsx,html}'],
theme: {
extend: {
colors: { brand: { 500: '#3370b8' } },
},
},
};<div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-white">
<button class="bg-brand-500 hover:bg-brand-600 dark:bg-brand-400 dark:hover:bg-brand-300">
Click
</button>
</div>빌드 결과:
/* light 버전 */
.bg-white { background-color: #fff; }
.text-gray-900 { color: #18181b; }
.bg-brand-500 { background-color: #3370b8; }
.hover\:bg-brand-600:hover { background-color: #2861a1; }
/* dark 버전 — 같은 색 변환의 또 다른 사본 */
.dark .dark\:bg-gray-900 { background-color: #18181b; }
.dark .dark\:text-white { color: #fff; }
.dark .dark\:bg-brand-400 { background-color: #4a8cd4; }
.dark .dark\:hover\:bg-brand-300:hover { background-color: #6ba3df; }문제: 같은 의도(“accent 배경”)를 bg-brand-500(light)과 bg-brand-400(dark)로 마크업에서 분기해야 한다. 새 테마(brand) 추가 → 모든 JSX에 jira:bg-... 추가.
Tailwind v4 — @theme (CSS-first config)
v4의 핵심 변화: 모든 토큰이 CSS variable로 빌드된다. dark: variant는 여전히 있지만, variable swap으로 동작 가능.
/* app.css */
@import "tailwindcss";
@theme {
--color-brand-500: #3370b8;
--color-brand-400: #4a8cd4;
--color-bg-surface: var(--color-white);
--color-bg-accent: var(--color-brand-500);
}
@layer base {
[data-theme="dark"] {
--color-bg-surface: var(--color-gray-900);
--color-bg-accent: var(--color-brand-400);
}
}{/* 마크업은 단 한 줄 — dark: prefix 불필요 */}
<div class="bg-bg-surface">
<button class="bg-bg-accent">Click</button>
</div>v4도
dark:bg-gray-900패턴을 지원하지만 권장은 semantic token + variable swap. 마크업이 깨끗하고, 새 테마 추가가 CSS 한 블록.
Panda CSS — 처음부터 런타임
// panda.config.ts
import { defineConfig } from '@pandacss/dev';
export default defineConfig({
conditions: { dark: '[data-theme=dark] &' },
theme: {
semanticTokens: {
colors: {
'bg.surface': { value: { base: '#ffffff', _dark: '#18181b' } },
'bg.accent': { value: { base: '#3370b8', _dark: '#4a8cd4' } },
},
},
},
});import { css } from 'styled-system/css';
<div className={css({ bg: 'bg.surface', color: 'fg.default' })}>
<button className={css({ bg: 'bg.accent', color: 'fg.on.accent' })}>
Click
</button>
</div>Panda 출력은 CSS variables 기반:
.bg_bg\.surface { background: var(--colors-bg-surface); }
:root { --colors-bg-surface: #ffffff; }
[data-theme=dark] { --colors-bg-surface: #18181b; }정량 비교 — 실제 번들 크기
가상의 디자인 시스템: 색 클래스 300개, hover/focus/active variant 3개, 다크 + 라이트 + 3 브랜드.
| 접근 | CSS 클래스 수 | 압축 후 크기 |
|---|---|---|
Tailwind v3 dark: prefix (라이트+다크) | ≈ 1,800 (300×3×2) | ≈ 45 KB |
Tailwind v3 + 3 brand variant (e.g. jira:) | ≈ 5,400 | ≈ 130 KB |
Tailwind v4 @theme + variable swap | ≈ 900 (variant만 곱) | ≈ 20 KB + selector 5KB |
| Panda CSS semantic tokens | ≈ 900 (variant만 곱) | ≈ 22 KB + selector 5KB |
5배 차이. Tailwind v4가 v3 대비 다크모드 + 멀티 브랜드 시나리오에서 번들 1/5로 줄어드는 이유.
런타임의 진짜 강점 — 사용자 토큰 주입
Shopify Storefront 같은 케이스. 가맹점이 자기 색을 런타임에 박아야 한다.
// 빌드된 CSS는 손대지 않고 — 가맹점별 inject
function applyStoreTheme(store: { primary: string; bg: string }) {
const root = document.documentElement.style;
root.setProperty('--color-bg-accent', store.primary);
root.setProperty('--color-bg-surface', store.bg);
}빌드타임 접근으로는 불가능하다 — 가맹점마다 CSS를 재빌드할 수 없으니까.
Tailwind v3 darkMode: 'media' vs 'class' vs ['class', '[data-theme="dark"]']
| 옵션 | 작동 | 비고 |
|---|---|---|
'media' | @media (prefers-color-scheme: dark) 안에 dark: 묶음 | 사용자 토글 불가 |
'class' | .dark 클래스 안에 dark: 묶음 | 가장 흔함 |
'selector' | 임의 selector ('[data-theme="dark"]') | v3.4.1+ |
['class', '[data-theme="dark"]'] | 두 selector 모두 | next-themes 호환 |
Tailwind v4 @variant
v4는 @variant dark 같은 유틸로 dark variant의 selector를 재정의:
@import "tailwindcss";
@variant dark (&:where([data-theme=dark], [data-theme=dark] *));이제 dark:bg-gray-900이 .dark 대신 [data-theme=dark]로 작동.
What-if — 잘못 쓰면 어떻게 깨지는가
- 함정 1 — Tailwind v3에서 모든 곳에
dark:박기: 마크업이 모든 색마다 2배가 된다. semantic token도 없어서 새 테마(brand) 추가 시 모든 JSX 수정. → Tailwind v3 그대로 갈 거면 config의theme.extend.colors에 semantic 이름을 추가하고 마크업은bg-surface bg-accent만 쓰라. - 함정 2 —
@apply안에서 dark: 분기:.btn { @apply bg-blue-500 dark:bg-blue-400; }— Tailwind v3에서 작동하지 않는다.@apply는 단일 유틸만 받고, variant는 못 받음. v4에서는 작동하지만 권장 X. - 함정 3 — CSS variable을 모든 곳에 박고 토큰 시스템 없이:
--my-button-color: #...처럼 컴포넌트별 variable을 컴포넌트 안에서 정의. cascade는 작동하지만 semantic 층이 없어 일관성 붕괴. - 함정 4 — variable의 fallback 누락:
background: var(--color-bg);—--color-bg가 정의 안 되면 투명. SSR에서 inline script 실패 시 모든 요소가 사라진다.var(--color-bg, #fff)같이 fallback 권장. - 함정 5 — Tailwind v4로 마이그레이션하면서 마크업의
dark:그대로 두기: v4의 진짜 강점은 마크업 정리.bg-white dark:bg-gray-900→bg-surface(semantic token)로 옮기지 않으면 v4의 이점이 절반만. - 함정 6 — IE11 호환 필요: CSS variables는 IE11 미지원. 이제는 IE 제로 죽었으니 빌드타임 접근의 명분이 없다.
Insight — 흥미로운 이야기
Tailwind v4가 왜 늦게 CSS variables 1급 시민으로 갔는가? Adam Wathan(Tailwind 창시자)의 2023년 v4 alpha 발표 영상에서의 답: “CSS Custom Properties가 모든 modern 브라우저에 안착하기까지 6년이 걸렸다.”
Tailwind 1.0이 2019년 출시될 때 CSS variables는 Safari 9.1 / iOS 9.3부터 지원이었다. 2020년 시점 Safari 13 미만 점유율이 무시할 수 없었고, 빌드타임 클래스가 유일하게 안정적인 선택지였다. 2024년에 와서야 Safari 14+ 점유율 99%를 넘어 CSS variables가 어디서나 쓸 수 있는 상태가 됐고, v4가 이를 받아 안는다.
또 하나의 반전: Stitches(2020) 와 vanilla-extract(2021) 가 CSS-in-JS 진영에서 먼저 “build CSS variables ahead-of-time” 패턴을 정착시켰다. Stitches는 Modulz 팀(나중에 Radix UI), vanilla-extract는 Seek 팀이 만든 zero-runtime CSS-in-JS. 둘 다 런타임 0, 빌드타임에 CSS variables 생성, 런타임 swap. Panda CSS는 이 두 라이브러리의 직계 후예다.
흥미로운 디테일: Tailwind v4의 @theme는 사실 Stitches의 createTheme() API를 의식한 것이다. Stitches는:
const darkTheme = createTheme({
colors: { background: '#000' },
});
// → 자동으로 .theme-dark-xyz 클래스가 생성되어 .dark { --colors-background: #000 } 같은 CSS 출력이 패턴을 Tailwind가 CSS-native로 다시 표현한 것이 @theme directive다. CSS의 최신 기능과 유틸리티 클래스 철학이 만난 지점.
마지막 통찰: 빌드타임은 정적이고 정확, 런타임은 유연하고 작다. 둘 중 무엇이 옳은가는 바뀌는 축이 컴파일 시점에 알려져 있는가에 달렸다. 다크모드, 멀티 브랜드, 사용자 토큰은 런타임에 결정되니 런타임 접근이 본질적으로 맞다. 빌드타임은 코드 조각의 사용 여부 같은 컴파일 시점 결정에 강하다 — 두 접근은 대체재가 아니라 보완재다.
요약
- 빌드타임 분기(Tailwind v3
dark:)는 모드 수에 비례해 CSS가 커진다. - 런타임 분기(CSS variables + selector)는 모드 수 무관 단일 CSS.
- 2025년 정답은 런타임 + semantic token. Tailwind v4의
@theme가 이를 1급 시민으로. - Panda CSS와 Tailwind v4는 같은 결론에 다른 길로 도달했다 — CSS variables + 빌드타임 토큰 정의.
- 빌드타임은 컴파일 시점 결정(코드 사용 여부)에 강하고, 런타임은 실행 시점 결정(테마, 사용자 토큰)에 강하다.