본문으로 이동
·16분 읽기

Tailwind CSS v4: 실제로 무엇이 바뀌었고 마이그레이션해야 할까

CSS 우선 설정, @layer 통합, 내장 컨테이너 쿼리, 새로운 엔진 성능, 브레이킹 체인지, v3에서 v4로의 솔직한 마이그레이션 경험.

공유:X / TwitterLinkedIn

Tailwind CSS를 v1.x 시절부터 사용해 왔습니다. 커뮤니티의 절반은 끔찍하다고 생각하고 나머지 절반은 멈추지 않고 제품을 출시하던 때였죠. 모든 메이저 버전이 상당한 도약이었지만, v4는 다릅니다. 단순한 기능 릴리스가 아닙니다. 프레임워크와의 근본적인 계약을 바꾸는 완전한 아키텍처 재작성입니다.

두 개의 프로덕션 프로젝트를 v3에서 v4로 마이그레이션하고 세 개의 새 프로젝트를 v4로 처음부터 시작한 후, 진정으로 나아진 점, 거친 부분, 그리고 지금 마이그레이션해야 하는지에 대한 명확한 그림을 갖게 되었습니다. 과대광고도 분노도 없이 — 제가 관찰한 것만 전합니다.

큰 그림: v4가 실제로 무엇인가#

Tailwind CSS v4는 세 가지를 동시에 담고 있습니다:

  1. 새로운 엔진 — JavaScript에서 Rust(Oxide 엔진)로 재작성되어 빌드가 극적으로 빨라졌습니다
  2. 새로운 설정 패러다임 — CSS 우선 설정이 기본값으로 tailwind.config.js를 대체합니다
  3. CSS 플랫폼과의 더 긴밀한 통합 — 네이티브 @layer, 컨테이너 쿼리, @starting-style, 캐스케이드 레이어가 일급 시민입니다

어디서나 보게 될 헤드라인은 "10배 빠름"입니다. 실제로 맞지만, 진짜 변화를 과소평가합니다. Tailwind를 설정하고 확장하는 멘탈 모델이 근본적으로 바뀌었습니다. 이제 CSS를 생성하는 JavaScript 설정 객체가 아니라 CSS 자체로 작업합니다.

최소한의 Tailwind v4 설정은 다음과 같습니다:

css
/* app.css — 전체 설정이 이것입니다 */
@import "tailwindcss";

이게 전부입니다. 설정 파일도 없고, PostCSS 플러그인 설정도(대부분의 경우) 없고, @tailwind base; @tailwind components; @tailwind utilities; 디렉티브도 없습니다. 한 줄의 import만으로 실행됩니다.

v3과 비교해 보세요:

css
/* v3 — app.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
js
// v3 — tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};
js
// v3 — postcss.config.js
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};

세 파일이 한 줄로 줄었습니다. 단순히 보일러플레이트가 적어진 것이 아닙니다 — 잘못된 설정이 발생할 수 있는 범위가 줄어든 것입니다. v4에서는 콘텐츠 감지가 자동입니다. glob 패턴을 직접 명시하지 않아도 프로젝트 파일을 스캔합니다.

@theme을 사용한 CSS 우선 설정#

이것이 가장 큰 개념적 변화입니다. v3에서는 JavaScript 설정 객체를 통해 Tailwind를 커스터마이징했습니다:

js
// v3 — tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: {
          50: "#eff6ff",
          500: "#3b82f6",
          900: "#1e3a5f",
        },
      },
      fontFamily: {
        display: ["Inter Variable", "sans-serif"],
      },
      spacing: {
        18: "4.5rem",
        112: "28rem",
      },
      borderRadius: {
        "4xl": "2rem",
      },
    },
  },
};

v4에서는 @theme 디렉티브를 사용하여 이 모든 것이 CSS에 존재합니다:

css
@import "tailwindcss";
 
@theme {
  --color-brand-50: #eff6ff;
  --color-brand-500: #3b82f6;
  --color-brand-900: #1e3a5f;
 
  --font-display: "Inter Variable", sans-serif;
 
  --spacing-18: 4.5rem;
  --spacing-112: 28rem;
 
  --radius-4xl: 2rem;
}

처음에는 이를 거부했습니다. 전체 디자인 시스템을 볼 수 있는 하나의 JavaScript 객체를 좋아했거든요. 하지만 CSS 접근 방식을 일주일간 사용한 후 세 가지 이유로 마음이 바뀌었습니다:

1. 네이티브 CSS 커스텀 프로퍼티가 자동으로 노출됩니다. @theme에서 정의하는 모든 값이 :root의 CSS 커스텀 프로퍼티가 됩니다. 이는 테마 값이 일반 CSS, CSS Modules, <style> 태그 등 CSS가 실행되는 모든 곳에서 접근 가능하다는 의미입니다:

css
/* 이것을 무료로 얻습니다 */
:root {
  --color-brand-50: #eff6ff;
  --color-brand-500: #3b82f6;
  --color-brand-900: #1e3a5f;
}
css
/* 어디서든 사용 가능 — Tailwind 필요 없음 */
.custom-element {
  border: 2px solid var(--color-brand-500);
}

2. @theme 내에서 CSS 기능을 사용할 수 있습니다. 미디어 쿼리, light-dark(), calc() — 실제 CSS가 여기서 작동합니다. 왜냐하면 실제 CSS이기 때문입니다:

css
@theme {
  --color-surface: light-dark(#ffffff, #0a0a0a);
  --color-text: light-dark(#0a0a0a, #fafafa);
  --spacing-container: calc(100vw - 2rem);
}

3. 다른 CSS와의 공존. 테마, 커스텀 유틸리티, 그리고 기본 스타일이 모두 같은 언어, 원한다면 같은 파일에 존재합니다. "CSS 세계"와 "JavaScript 설정 세계" 사이의 컨텍스트 전환이 없습니다.

기본 테마 재정의 vs 확장#

v3에서는 theme(교체)과 theme.extend(병합)이 있었습니다. v4에서는 멘탈 모델이 다릅니다:

css
@import "tailwindcss";
 
/* 이것은 기본 테마를 확장합니다 — 기존 색상과 함께 브랜드 색상을 추가합니다 */
@theme {
  --color-brand-500: #3b82f6;
}

네임스페이스를 완전히 교체하려면(모든 기본 색상 제거 등), --color-* 와일드카드 리셋과 함께 @theme를 사용합니다:

css
@import "tailwindcss";
 
@theme {
  /* 먼저 모든 기본 색상을 초기화 */
  --color-*: initial;
 
  /* 이제 자신의 색상만 정의 */
  --color-white: #ffffff;
  --color-black: #000000;
  --color-brand-50: #eff6ff;
  --color-brand-500: #3b82f6;
  --color-brand-900: #1e3a5f;
}

이 와일드카드 리셋 패턴은 우아합니다. 기본 테마의 어떤 부분을 유지하고 어떤 부분을 교체할지 정확히 선택할 수 있습니다. 기본 간격은 모두 유지하되 색상은 커스텀으로? --color-*: initial;만 리셋하고 간격은 그대로 두면 됩니다.

다중 테마 파일#

더 큰 프로젝트에서는 테마를 여러 파일로 분할할 수 있습니다:

css
/* styles/theme/colors.css */
@theme {
  --color-brand-50: #eff6ff;
  --color-brand-100: #dbeafe;
  --color-brand-200: #bfdbfe;
  --color-brand-300: #93c5fd;
  --color-brand-400: #60a5fa;
  --color-brand-500: #3b82f6;
  --color-brand-600: #2563eb;
  --color-brand-700: #1d4ed8;
  --color-brand-800: #1e40af;
  --color-brand-900: #1e3a5f;
  --color-brand-950: #172554;
}
 
/* styles/theme/typography.css */
@theme {
  --font-display: "Inter Variable", sans-serif;
  --font-body: "Source Sans 3 Variable", sans-serif;
  --font-mono: "JetBrains Mono Variable", monospace;
 
  --text-display: 3.5rem;
  --text-display--line-height: 1.1;
  --text-display--letter-spacing: -0.02em;
}
css
/* app.css */
@import "tailwindcss";
@import "./theme/colors.css";
@import "./theme/typography.css";

이것은 거대한 tailwind.config.js를 갖거나 require()로 분할하려는 v3 패턴보다 훨씬 깔끔합니다.

Oxide 엔진: 실제로 10배 빠릅니다#

Tailwind v4의 엔진은 Rust로 완전히 재작성되었습니다. Oxide라고 부릅니다. "10배 빠름"이라는 주장에 회의적이었습니다 — 마케팅 수치가 실제 프로젝트에서 살아남는 경우는 드물기 때문입니다. 그래서 벤치마크를 했습니다.

테스트 프로젝트: 847개 컴포넌트, 142개 페이지, 약 23,000개의 Tailwind 클래스 사용이 있는 Next.js 앱.

지표v3 (Node)v4 (Oxide)개선
초기 빌드4,280ms387ms11배
증분(파일 1개 편집)340ms18ms19배
전체 재빌드(클린)5,100ms510ms10배
개발 서버 시작3,200ms290ms11배

"10배" 주장은 제 프로젝트에서는 보수적입니다. 증분 빌드가 진짜 빛나는 부분입니다 — 18ms는 본질적으로 즉각적입니다. 파일을 저장하면 탭을 전환하기 전에 브라우저에 새 스타일이 적용되어 있습니다.

왜 이렇게 빠른가?#

세 가지 이유가 있습니다:

1. JavaScript 대신 Rust. 핵심 CSS 파서, 클래스 감지, 코드 생성이 모두 네이티브 Rust입니다. "재미로 Rust로 재작성하자"는 상황이 아닙니다 — CSS 파싱은 진정으로 CPU 바운드 작업으로, 네이티브 코드가 V8에 비해 막대한 이점을 갖습니다.

2. 핫 패스에 PostCSS가 없습니다. v3에서 Tailwind는 PostCSS 플러그인이었습니다. 모든 빌드는 CSS를 PostCSS AST로 파싱, Tailwind 플러그인 실행, CSS 문자열로 다시 직렬화, 그리고 다른 PostCSS 플러그인 실행을 의미했습니다. v4에서 Tailwind는 소스에서 출력까지 직접 가는 자체 CSS 파서를 갖고 있습니다. PostCSS는 호환성을 위해 여전히 지원되지만, 주요 경로는 완전히 건너뜁니다.

3. 더 똑똑한 증분 처리. 새 엔진은 공격적으로 캐싱합니다. 단일 파일을 편집하면 해당 파일만 클래스 이름을 다시 스캔하고 변경된 CSS 규칙만 다시 생성합니다. v3 엔진도 이 부분에서 사람들이 인정하는 것보다 더 똑똑했지만(JIT 모드가 이미 증분적이었습니다), v4는 세밀한 의존성 추적으로 훨씬 더 나아갔습니다.

속도가 정말 중요한가?#

네, 하지만 여러분이 예상하는 이유 때문은 아닙니다. 대부분의 프로젝트에서 v3의 빌드 속도는 "괜찮았습니다." 개발 중에 수백 밀리초를 기다렸죠. 고통스럽지는 않았습니다.

v4의 속도가 중요한 이유는 Tailwind를 도구 체인에서 보이지 않게 만들기 때문입니다. 빌드가 20ms 미만이면 Tailwind를 빌드 단계로 전혀 인식하지 않게 됩니다. 구문 강조처럼 — 항상 거기 있지만 절대 방해하지 않습니다. 그 심리적 차이가 하루 종일 개발할 때 중요합니다.

네이티브 @layer 통합#

v3에서 Tailwind는 @layer base, @layer components, @layer utilities로 자체 레이어 시스템을 사용했습니다. CSS 캐스케이드 레이어처럼 보였지만 그렇지 않았습니다 — 생성된 CSS가 출력에서 어디에 나타나는지를 제어하는 Tailwind 전용 디렉티브였습니다.

v4에서 Tailwind는 실제 CSS 캐스케이드 레이어를 사용합니다:

css
/* v4 출력 — 간소화 */
@layer theme, base, components, utilities;
 
@layer base {
  /* 리셋, preflight */
}
 
@layer components {
  /* 컴포넌트 클래스 */
}
 
@layer utilities {
  /* 모든 생성된 유틸리티 클래스 */
}

이것은 CSS 캐스케이드 레이어가 실제 구체성(specificity) 영향을 미치기 때문에 중요한 변화입니다. 낮은 우선순위 레이어의 규칙은 셀렉터 구체성과 관계없이 항상 높은 우선순위 레이어의 규칙에 집니다. 즉:

css
@layer components {
  /* 구체성: 0-1-0 */
  .card { padding: 1rem; }
}
 
@layer utilities {
  /* 구체성: 0-1-0 — 같은 구체성이지만 utilities 레이어가 나중이므로 승리 */
  .p-4 { padding: 1rem; }
}

유틸리티는 항상 컴포넌트를 재정의합니다. 컴포넌트는 항상 기본을 재정의합니다. 이것이 v3에서 Tailwind가 개념적으로 작동하던 방식이었지만, 이제는 소스 순서 조작이 아닌 브라우저의 캐스케이드 레이어 메커니즘에 의해 강제됩니다.

커스텀 유틸리티 추가#

v3에서는 플러그인 API 또는 @layer utilities로 커스텀 유틸리티를 정의했습니다:

js
// v3 — 플러그인 접근 방식
const plugin = require("tailwindcss/plugin");
 
module.exports = {
  plugins: [
    plugin(function ({ addUtilities }) {
      addUtilities({
        ".text-balance": {
          "text-wrap": "balance",
        },
        ".text-pretty": {
          "text-wrap": "pretty",
        },
      });
    }),
  ],
};

v4에서 커스텀 유틸리티는 @utility 디렉티브로 정의됩니다:

css
@import "tailwindcss";
 
@utility text-balance {
  text-wrap: balance;
}
 
@utility text-pretty {
  text-wrap: pretty;
}

@utility 디렉티브는 Tailwind에게 "이것은 유틸리티 클래스입니다 — utilities 레이어에 넣고 변형과 함께 사용할 수 있게 하세요"라고 알려줍니다. 마지막 부분이 핵심입니다. @utility로 정의된 유틸리티는 hover:, focus:, md:, 그리고 모든 다른 변형과 자동으로 작동합니다:

html
<p class="text-pretty md:text-balance">...</p>

커스텀 변형#

@variant로 커스텀 변형도 정의할 수 있습니다:

css
@import "tailwindcss";
 
@variant hocus (&:hover, &:focus);
@variant theme-dark (.dark &);
html
<button class="hocus:bg-brand-500 theme-dark:text-white">
  클릭하세요
</button>

이것은 대부분의 사용 사례에서 v3의 addVariant 플러그인 API를 대체합니다. 덜 강력하지만(프로그래밍 방식의 변형 생성은 할 수 없습니다), 사람들이 실제로 하는 것의 90%를 커버합니다.

컨테이너 쿼리: 내장, 플러그인 불필요#

컨테이너 쿼리는 v3에서 가장 많이 요청된 기능 중 하나였습니다. @tailwindcss/container-queries 플러그인으로 사용할 수 있었지만, 부가 기능이었습니다. v4에서는 프레임워크에 내장되어 있습니다.

기본 사용법#

@container로 컨테이너를 표시하고 @ 접두사로 크기를 쿼리합니다:

html
<!-- 부모를 컨테이너로 표시 -->
<div class="@container">
  <!-- 뷰포트가 아닌 부모의 너비에 반응 -->
  <div class="flex flex-col @md:flex-row @lg:grid @lg:grid-cols-3">
    <div class="p-4">카드 1</div>
    <div class="p-4">카드 2</div>
    <div class="p-4">카드 3</div>
  </div>
</div>

@md, @lg 등의 변형은 반응형 브레이크포인트처럼 작동하지만 뷰포트 대신 가장 가까운 @container 조상을 기준으로 합니다. 브레이크포인트 값은 Tailwind의 기본 브레이크포인트에 해당합니다:

변형최소 너비
@sm24rem (384px)
@md28rem (448px)
@lg32rem (512px)
@xl36rem (576px)
@2xl42rem (672px)

이름 있는 컨테이너#

컨테이너에 이름을 붙여 특정 조상을 쿼리할 수 있습니다:

html
<div class="@container/sidebar">
  <div class="@container/card">
    <!-- card 컨테이너를 쿼리 -->
    <div class="@md/card:text-lg">...</div>
 
    <!-- sidebar 컨테이너를 쿼리 -->
    <div class="@lg/sidebar:hidden">...</div>
  </div>
</div>

왜 이것이 중요한가#

컨테이너 쿼리는 반응형 디자인에 대한 사고 방식을 바꿉니다. "이 뷰포트 너비에서 세 열을 보여줘" 대신 "이 컴포넌트의 컨테이너가 충분히 넓으면 세 열을 보여줘"라고 말합니다. 컴포넌트가 진정으로 자기 완결적이 됩니다. 카드 컴포넌트를 전체 너비 레이아웃에서 사이드바로 옮겨도 자동으로 적응합니다. 미디어 쿼리 곡예가 필요 없습니다.

컴포넌트 라이브러리를 뷰포트 브레이크포인트 대신 컨테이너 쿼리를 기본으로 사용하도록 리팩토링해 왔습니다. 결과는 배치하는 곳 어디에서나 작동하는 컴포넌트이며, 부모가 컴포넌트의 반응형 동작에 대해 아무것도 알 필요가 없습니다.

html
<!-- 이 컴포넌트는 배치된 모든 컨테이너에 적응합니다 -->
<article class="@container">
  <div class="grid grid-cols-1 @md:grid-cols-[200px_1fr] gap-4">
    <img
      class="w-full @md:w-auto rounded-lg aspect-video @md:aspect-square object-cover"
      src="/post-image.jpg"
      alt=""
    />
    <div>
      <h2 class="text-lg @lg:text-xl font-semibold">게시글 제목</h2>
      <p class="mt-2 text-sm @md:text-base text-gray-600">
        게시글 발췌 내용이 여기에 들어갑니다...
      </p>
      <div class="mt-4 hidden @md:flex gap-2">
        <span class="text-xs bg-gray-100 px-2 py-1 rounded">태그</span>
      </div>
    </div>
  </div>
</article>

실제로 중요한 새로운 변형#

v4는 제가 계속 손이 가는 여러 새로운 변형을 추가합니다. 실제 공백을 채워줍니다.

starting: 변형#

이것은 CSS @starting-style에 매핑되어 요소가 처음 나타날 때의 초기 상태를 정의할 수 있게 합니다. JavaScript 없이 요소 진입 애니메이션을 구현하는 데 빠져 있던 퍼즐 조각입니다:

html
<dialog class="opacity-0 starting:opacity-0 open:opacity-100 transition-opacity duration-300">
  <p>이 다이얼로그는 열릴 때 페이드인됩니다</p>
</dialog>

starting: 변형은 @starting-style 블록 내에 CSS를 생성합니다:

css
/* Tailwind가 생성하는 것 */
@starting-style {
  dialog[open] {
    opacity: 0;
  }
}
 
dialog[open] {
  opacity: 1;
  transition: opacity 300ms;
}

다이얼로그, 팝오버, 드롭다운 메뉴 — 진입 애니메이션이 필요한 모든 것에 매우 유용합니다. 이전에는 다음 프레임에서 클래스를 추가하기 위해 JavaScript가 필요했거나 @keyframes를 사용해야 했습니다. 이제는 유틸리티 클래스입니다.

not-* 변형#

부정. 오래전부터 원했던 것:

html
<!-- 마지막을 제외한 모든 자식에 테두리 적용 -->
<div class="divide-y">
  <div class="not-last:pb-4">항목 1</div>
  <div class="not-last:pb-4">항목 2</div>
  <div class="not-last:pb-4">항목 3</div>
</div>
 
<!-- 비활성화되지 않은 모든 것에 스타일 적용 -->
<input class="not-disabled:hover:border-brand-500" />
 
<!-- data 속성 부정 -->
<div class="not-data-active:opacity-50">...</div>

nth-* 변형#

직접적인 nth-childnth-of-type 접근:

html
<ul>
  <li class="nth-1:font-bold">첫 번째 항목 — 굵게</li>
  <li class="nth-even:bg-gray-50">짝수 행 — 회색 배경</li>
  <li class="nth-odd:bg-white">홀수 행 — 흰색 배경</li>
  <li class="nth-[3n+1]:text-brand-500">3번째+1마다 — 브랜드 색상</li>
</ul>

대괄호 구문(nth-[3n+1])은 유효한 모든 nth-child 표현식을 지원합니다. 테이블 줄무늬와 그리드 패턴을 위해 작성하던 많은 커스텀 CSS를 대체합니다.

in-* 변형 (부모 상태)#

이것은 group-*의 반대입니다. "내 부모(group)가 호버되면 나를 스타일링해줘" 대신 "이 상태와 일치하는 부모 안에 있을 때 나를 스타일링해줘"입니다:

html
<div class="in-data-active:bg-brand-50">
  조상에 data-active가 있으면 배경이 적용됩니다
</div>

**: 깊은 범용 변형#

직접 자식뿐만 아니라 모든 자손에 스타일을 적용합니다. 이것은 제어된 힘입니다 — 아끼 사용하세요. 하지만 산문 콘텐츠와 CMS 출력에는 매우 유용합니다:

html
<!-- 이 div 안의 모든 단락, 모든 깊이에서 -->
<div class="**:data-highlight:bg-yellow-100">
  <section>
    <p data-highlight>이것이 강조됩니다</p>
    <div>
      <p data-highlight>이것도, 더 깊이 중첩되어도</p>
    </div>
  </section>
</div>

브레이킹 체인지: 실제로 무엇이 깨졌나#

솔직히 말하겠습니다. 큰 v3 프로젝트가 있다면 마이그레이션은 사소하지 않습니다. 제 프로젝트에서 깨진 것들입니다:

1. 설정 형식#

tailwind.config.js가 바로 작동하지 않습니다. 다음 중 하나를 해야 합니다:

  • @theme CSS로 변환 (새 아키텍처에 권장)
  • 호환성 레이어 @config 디렉티브 사용 (빠른 마이그레이션 경로)
css
/* 빠른 마이그레이션 — 기존 설정 유지 */
@import "tailwindcss";
@config "../../tailwind.config.js";

@config 브리지는 작동하지만 명시적으로 마이그레이션 도구입니다. 시간이 지남에 따라 @theme으로 이동하는 것이 권장됩니다.

2. 제거된 사용 중단 유틸리티#

v3에서 사용 중단된 일부 유틸리티가 사라졌습니다:

/* v4에서 제거됨 */
bg-opacity-*     → bg-black/50 사용 (슬래시 불투명도 구문)
text-opacity-*   → text-black/50 사용
border-opacity-* → border-black/50 사용
flex-shrink-*    → shrink-* 사용
flex-grow-*      → grow-* 사용
overflow-ellipsis → text-ellipsis 사용
decoration-slice  → box-decoration-slice 사용
decoration-clone  → box-decoration-clone 사용

이미 v3에서 최신 구문을 사용하고 있었다면(슬래시 불투명도, shrink-*) 괜찮습니다. 아니라면 간단한 찾기-바꾸기 변경입니다.

3. 기본 색상 팔레트 변경#

기본 색상 팔레트가 약간 변경되었습니다. v3의 정확한 색상 값(이름이 아닌 실제 hex 값)에 의존하고 있다면 시각적 차이를 느낄 수 있습니다. 이름 있는 색상(blue-500, gray-200)은 여전히 존재하지만 일부 hex 값이 변경되었습니다.

4. 콘텐츠 감지#

v3에서는 명시적 content 설정이 필요했습니다:

js
// v3
module.exports = {
  content: [
    "./src/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
};

v4는 자동 콘텐츠 감지를 사용합니다. 프로젝트 루트를 스캔하여 템플릿 파일을 자동으로 찾습니다. 대부분 "그냥 작동"하지만, 비정상적인 프로젝트 구조(프로젝트 루트 밖에 패키지가 있는 모노레포, 예상치 못한 위치의 템플릿 파일)가 있으면 소스 경로를 명시적으로 설정해야 할 수 있습니다:

css
@import "tailwindcss";
@source "../shared-components/**/*.tsx";
@source "../design-system/src/**/*.tsx";

5. 플러그인 API 변경#

커스텀 플러그인을 작성했다면 API가 변경되었습니다. addUtilities, addComponents, addBase, addVariant 함수는 호환성 레이어를 통해 여전히 작동하지만, 관용적인 v4 접근 방식은 CSS 네이티브입니다:

js
// v3 플러그인
plugin(function ({ addUtilities, theme }) {
  addUtilities({
    ".scrollbar-hide": {
      "-ms-overflow-style": "none",
      "scrollbar-width": "none",
      "&::-webkit-scrollbar": {
        display: "none",
      },
    },
  });
});
css
/* v4 — 그냥 CSS */
@utility scrollbar-hide {
  -ms-overflow-style: none;
  scrollbar-width: none;
  &::-webkit-scrollbar {
    display: none;
  }
}

대부분의 퍼스트파티 플러그인(@tailwindcss/typography, @tailwindcss/forms, @tailwindcss/aspect-ratio)에 v4 호환 버전이 있습니다. 서드파티 플러그인은 지원이 불확실합니다 — 마이그레이션 전에 해당 저장소를 확인하세요.

6. JIT가 유일한 모드#

v3에서는 JIT 모드를 선택 해제할 수 있었습니다(거의 아무도 하지 않았지만). v4에서는 비-JIT 모드가 없습니다. 모든 것이 항상 온디맨드로 생성됩니다. 이전 AOT(사전 컴파일) 엔진을 사용할 이유가 있었다면, 그 경로는 사라졌습니다.

7. 일부 변형 구문 변경#

일부 변형이 이름이 바뀌거나 동작이 변경되었습니다:

html
<!-- v3 -->
<div class="[&>*]:p-4">...</div>
 
<!-- v4 — >* 부분이 이제 인셋 변형 구문을 사용합니다 -->
<div class="*:p-4">...</div>

임의 변형 구문 [&...]은 여전히 작동하지만, v4는 일반적인 패턴에 대해 이름 있는 대안을 제공합니다.

마이그레이션 가이드: 실제 프로세스#

제가 실제로 마이그레이션한 방법입니다. 문서의 행복한 경로가 아니라 프로세스가 실제로 어떤 모습이었는지 말씀드립니다.

1단계: 공식 코드모드 실행#

Tailwind는 대부분의 기계적 변경을 처리하는 코드모드를 제공합니다:

bash
npx @tailwindcss/upgrade

이것은 많은 것을 자동으로 처리합니다:

  • @tailwind 디렉티브를 @import "tailwindcss"로 변환
  • 사용 중단된 유틸리티 클래스 이름 변경
  • 변형 구문 업데이트
  • 불투명도 유틸리티를 슬래시 구문으로 변환 (bg-opacity-50bg-black/50으로)
  • 설정에서 기본 @theme 블록 생성

코드모드가 잘 처리하는 것#

  • 유틸리티 클래스 이름 변경 (거의 완벽)
  • 디렉티브 구문 변경
  • 간단한 테마 값 (색상, 간격, 폰트)
  • 불투명도 구문 마이그레이션

코드모드가 처리하지 못하는 것#

  • 복잡한 플러그인 변환
  • 동적 설정 값 (JavaScript의 theme() 호출)
  • 조건부 테마 설정 (예: 환경에 따른 테마 값)
  • 커스텀 플러그인 API 마이그레이션
  • 새 파서가 다르게 해석하는 임의 값 엣지 케이스
  • JavaScript에서 동적으로 구성된 클래스 이름 (템플릿 리터럴, 문자열 연결)

2단계: PostCSS 설정 수정#

대부분의 설정에서 PostCSS 설정을 업데이트합니다:

js
// postcss.config.js — v4
module.exports = {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};

참고: 플러그인 이름이 tailwindcss에서 @tailwindcss/postcss로 변경되었습니다. Vite를 사용 중이라면 PostCSS를 완전히 건너뛰고 Vite 플러그인을 사용할 수 있습니다:

js
// vite.config.ts
import tailwindcss from "@tailwindcss/vite";
 
export default defineConfig({
  plugins: [tailwindcss()],
});

3단계: 테마 설정 변환#

이것이 수동 작업 부분입니다. tailwind.config.js 테마 값을 가져와 @theme으로 변환합니다:

js
// v3 설정 — 이전
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: {
          light: "#60a5fa",
          DEFAULT: "#3b82f6",
          dark: "#1d4ed8",
        },
      },
      fontSize: {
        "2xs": ["0.65rem", { lineHeight: "1rem" }],
      },
      animation: {
        "fade-in": "fade-in 0.5s ease-out",
      },
      keyframes: {
        "fade-in": {
          "0%": { opacity: "0" },
          "100%": { opacity: "1" },
        },
      },
    },
  },
};
css
/* v4 CSS — 이후 */
@import "tailwindcss";
 
@theme {
  --color-brand-light: #60a5fa;
  --color-brand: #3b82f6;
  --color-brand-dark: #1d4ed8;
 
  --text-2xs: 0.65rem;
  --text-2xs--line-height: 1rem;
 
  --animate-fade-in: fade-in 0.5s ease-out;
}
 
@keyframes fade-in {
  0% { opacity: 0; }
  100% { opacity: 1; }
}

keyframes가 @theme 밖으로 나와 일반 CSS @keyframes가 된다는 것에 주목하세요. @theme의 애니메이션 이름은 그것들을 참조만 합니다. 이것이 더 깔끔합니다 — keyframes는 CSS이고, CSS로 작성되어야 합니다.

4단계: 시각적 회귀 테스트#

이것은 타협할 수 없습니다. 마이그레이션 후 앱의 모든 페이지를 열어 시각적으로 확인했습니다. Playwright 스크린샷 테스트도 실행했습니다(있다면). 코드모드는 좋지만 완벽하지 않습니다. 시각적 검토에서 발견한 것들:

  • 불투명도 구문 마이그레이션이 약간 다른 결과를 만든 몇 곳
  • 이전되지 않은 커스텀 플러그인 출력
  • 레이어 순서로 인한 z-index 스태킹 변경
  • 캐스케이드 레이어에서 다르게 동작하는 일부 !important 오버라이드

5단계: 서드파티 의존성 업데이트#

모든 Tailwind 관련 패키지를 확인하세요:

json
{
  "@tailwindcss/typography": "^1.0.0",
  "@tailwindcss/forms": "^1.0.0",
  "@tailwindcss/container-queries": "제거 — 이제 내장됨",
  "tailwindcss-animate": "v4 지원 확인",
  "prettier-plugin-tailwindcss": "최신으로 업데이트"
}

@tailwindcss/container-queries 플러그인은 더 이상 필요하지 않습니다 — 컨테이너 쿼리가 내장되어 있습니다. 다른 플러그인은 v4 호환 버전이 필요합니다.

Next.js와 함께 사용하기#

대부분의 프로젝트에 Next.js를 사용하므로 구체적인 설정을 안내합니다.

PostCSS 접근 방식 (Next.js에 권장)#

Next.js는 내부적으로 PostCSS를 사용하므로 PostCSS 플러그인이 자연스러운 선택입니다:

bash
npm install tailwindcss @tailwindcss/postcss
js
// postcss.config.mjs
export default {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};
css
/* app/globals.css */
@import "tailwindcss";
 
@theme {
  --font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
  --font-mono: "JetBrains Mono Variable", ui-monospace, monospace;
}

이것이 완전한 설정입니다. tailwind.config.js도 없고, autoprefixer도 없습니다(v4가 내부적으로 벤더 프리픽스를 처리합니다).

CSS 임포트 순서#

한 가지 걸려 넘어진 점: 캐스케이드 레이어 때문에 v4에서는 CSS 임포트 순서가 더 중요합니다. @import "tailwindcss"가 커스텀 스타일 전에 와야 합니다:

css
/* 올바른 순서 */
@import "tailwindcss";
@import "./theme.css";
@import "./custom-utilities.css";
 
/* 인라인 @theme, @utility 등 */

Tailwind 전에 커스텀 CSS를 임포트하면 스타일이 낮은 캐스케이드 레이어에 들어가 예기치 않게 재정의될 수 있습니다.

다크 모드#

다크 모드는 개념적으로 같은 방식으로 작동하지만 설정이 CSS로 이동했습니다:

css
@import "tailwindcss";
 
/* 클래스 기반 다크 모드 사용 (기본값은 미디어 기반) */
@variant dark (&:where(.dark, .dark *));

이것은 v3 설정을 대체합니다:

js
// v3
module.exports = {
  darkMode: "class",
};

@variant 접근 방식이 더 유연합니다. 다크 모드를 원하는 대로 정의할 수 있습니다 — 클래스 기반, data-attribute 기반, 또는 미디어 쿼리 기반:

css
/* data attribute 접근 방식 */
@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
 
/* 미디어 쿼리 — 이것이 기본값이므로 선언할 필요 없음 */
@variant dark (@media (prefers-color-scheme: dark));

Turbopack 호환성#

Turbopack(현재 기본 개발 번들러)과 함께 Next.js를 사용 중이라면 v4가 잘 작동합니다. Rust 엔진이 Turbopack 자체의 Rust 기반 아키텍처와 잘 맞습니다. 개발 시작 시간을 측정했습니다:

설정v3 + Webpackv3 + Turbopackv4 + Turbopack
콜드 스타트4.8s2.1s1.3s
HMR (CSS 변경)450ms180ms40ms

CSS 변경에 대한 40ms HMR은 거의 인지할 수 없습니다. 즉각적으로 느껴집니다.

성능 심층 분석: 빌드 속도를 넘어서#

Oxide 엔진의 이점은 원시 빌드 속도를 넘어섭니다.

메모리 사용량#

v4는 메모리를 상당히 적게 사용합니다. 847개 컴포넌트 프로젝트에서:

지표v3v4
피크 메모리 (빌드)380MB45MB
정상 상태 (개발)210MB28MB

이것은 메모리가 제한된 CI/CD 파이프라인과 동시에 열 개의 프로세스를 실행하는 개발 머신에서 중요합니다.

CSS 출력 크기#

v4는 새 엔진이 중복 제거와 데드 코드 제거에 더 뛰어나기 때문에 약간 더 작은 CSS 출력을 생성합니다:

v3 출력: 34.2 KB (gzipped)
v4 출력: 29.8 KB (gzipped)

코드를 변경하지 않고 13% 감소. 혁신적이지는 않지만 공짜 성능입니다.

테마 값의 트리 셰이킹#

v4에서 테마 값을 정의했지만 템플릿에서 사용하지 않으면 해당 CSS 커스텀 프로퍼티는 여전히 출력됩니다(@theme에 있으므로 :root 변수에 매핑됨). 하지만 사용하지 않는 값의 유틸리티 클래스는 생성되지 않습니다. 이것은 v3의 JIT 동작과 같지만 주목할 가치가 있습니다: CSS 커스텀 프로퍼티는 유틸리티 사용이 없어도 항상 사용 가능합니다.

특정 테마 값이 CSS 커스텀 프로퍼티를 생성하는 것을 방지하려면 @theme inline을 사용할 수 있습니다:

css
@theme inline {
  /* 이 값들은 유틸리티를 생성하지만 CSS 커스텀 프로퍼티는 생성하지 않음 */
  --color-internal-debug: #ff00ff;
  --spacing-magic-number: 3.7rem;
}

이것은 CSS 변수로 노출하고 싶지 않은 내부 디자인 토큰에 유용합니다.

고급: 멀티 브랜드를 위한 테마 구성#

v4가 훨씬 쉽게 만드는 패턴 중 하나는 멀티 브랜드 테마입니다. 테마 값이 CSS 커스텀 프로퍼티이기 때문에 런타임에 교체할 수 있습니다:

css
@import "tailwindcss";
 
@theme {
  --color-brand: var(--brand-primary, #3b82f6);
  --color-brand-light: var(--brand-light, #60a5fa);
  --color-brand-dark: var(--brand-dark, #1d4ed8);
}
 
/* 브랜드 오버라이드 */
.theme-acme {
  --brand-primary: #e11d48;
  --brand-light: #fb7185;
  --brand-dark: #9f1239;
}
 
.theme-globex {
  --brand-primary: #059669;
  --brand-light: #34d399;
  --brand-dark: #047857;
}
html
<body class="theme-acme">
  <!-- 모든 bg-brand, text-brand 등이 Acme 색상을 사용 -->
  <div class="bg-brand text-white">Acme Corp</div>
</body>

v3에서는 Tailwind 외부에서 커스텀 플러그인이나 복잡한 CSS 변수 설정이 필요했습니다. v4에서는 자연스럽습니다 — 테마가 CSS 변수이고, CSS 변수는 캐스케이드합니다. 이것이 CSS 우선 접근 방식이 옳다고 느끼게 하는 부분입니다.

v3에서 그리운 것들#

균형 잡힌 시각을 제공하겠습니다. v3이 했고 v4에서 진심으로 그리운 것들이 있습니다:

1. 프로그래밍 방식 테마를 위한 JavaScript 설정. 단일 브랜드 색상에서 JavaScript 함수를 사용하여 설정에서 색상 스케일을 생성하는 프로젝트가 있었습니다. v4에서는 @theme에서 그렇게 할 수 없습니다 — CSS 파일을 생성하는 빌드 단계가 필요하거나 색상을 한 번 계산해서 붙여넣어야 합니다. @config 호환성 레이어가 도움이 되지만 장기적인 해결책은 아닙니다.

2. IntelliSense가 출시 시 더 좋았습니다. v3 VS Code 확장은 수년간의 다듬기가 있었습니다. v4 IntelliSense는 작동하지만 초기에 일부 공백이 있었습니다 — 커스텀 @theme 값이 때때로 자동 완성되지 않았고, @utility 정의가 항상 인식되지는 않았습니다. 최근 업데이트로 크게 개선되었지만 주목할 가치가 있습니다.

3. 생태계 성숙도. v3 주변의 생태계는 거대했습니다. Headless UI, Radix, shadcn/ui, Flowbite, DaisyUI — 모든 것이 v3에 대해 테스트되었습니다. v4 지원이 출시되고 있지만 보편적이지는 않습니다. 한 컴포넌트 라이브러리의 v4 호환성을 수정하기 위해 PR을 제출해야 했습니다.

마이그레이션해야 할까?#

v4와 몇 주간 함께한 후의 제 결정 프레임워크입니다:

지금 마이그레이션할 경우:#

  • 새 프로젝트를 시작하는 경우 (당연한 선택 — v4로 시작)
  • 프로젝트에 커스텀 플러그인이 최소인 경우
  • 대규모 프로젝트에서 성능 이점을 원하는 경우
  • 이미 최신 Tailwind 패턴을 사용 중인 경우 (슬래시 불투명도, shrink-* 등)
  • 컨테이너 쿼리가 필요하고 플러그인을 추가하고 싶지 않은 경우

기다릴 경우:#

  • v4를 아직 지원하지 않는 서드파티 Tailwind 플러그인에 크게 의존하는 경우
  • 복잡한 프로그래밍 방식 테마 설정이 있는 경우
  • 프로젝트가 안정적이고 활발히 개발되지 않는 경우 (왜 건드리나요?)
  • 기능 스프린트 중인 경우 (스프린트 사이에 마이그레이션, 중간에 하지 마세요)

마이그레이션하지 말 경우:#

  • v2 이하를 사용 중인 경우 (먼저 v3으로 업그레이드, 안정화, 그런 다음 v4 고려)
  • 프로젝트가 앞으로 몇 달 안에 끝나는 경우 (변경의 가치가 없음)

솔직한 의견#

새 프로젝트에서 v4는 당연한 선택입니다. CSS 우선 설정이 더 깔끔하고, 엔진이 극적으로 빠르며, 새로운 기능(컨테이너 쿼리, @starting-style, 새 변형)이 진정으로 유용합니다.

기존 프로젝트에서는 단계적 접근을 권장합니다:

  1. 지금: 모든 새 프로젝트를 v4로 시작
  2. 곧: 작은 내부 프로젝트를 v4로 변환하여 실험
  3. 준비되면: 시각적 회귀 테스트와 함께 조용한 스프린트 동안 프로덕션 프로젝트 마이그레이션

준비하면 마이그레이션은 고통스럽지 않습니다. 코드모드가 작업의 80%를 처리합니다. 나머지 20%는 수동이지만 간단합니다. 중간 규모 프로젝트에 하루, 대규모 프로젝트에 2~3일을 예산으로 잡으세요.

Tailwind v4는 Tailwind가 처음부터 되었어야 할 모습입니다. JavaScript 설정은 항상 당시 도구에 대한 타협이었습니다. CSS 우선 설정, 네이티브 캐스케이드 레이어, Rust 엔진 — 이것들은 트렌드가 아닙니다. 프레임워크가 플랫폼을 따라잡는 것입니다. 웹 플랫폼이 나아졌고, Tailwind v4는 그것과 싸우는 대신 그것에 기대고 있습니다.

디자인 토큰을 CSS로 작성하고, CSS 기능으로 구성하고, 브라우저 자체의 캐스케이드가 구체성을 처리하게 하는 방향 — 이것이 올바른 방향입니다. 여기까지 네 번의 메이저 버전이 걸렸지만, 결과는 지금까지 가장 일관된 Tailwind입니다.

다음 프로젝트를 이것으로 시작하세요. 뒤돌아보지 않게 될 것입니다.

관련 게시물