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-900bg-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 + 빌드타임 토큰 정의.
  • 빌드타임은 컴파일 시점 결정(코드 사용 여부)에 강하고, 런타임은 실행 시점 결정(테마, 사용자 토큰)에 강하다.