Tailwind v4 다크 모드가 동작하지 않았던 이유: 상태 문제가 아니라 빌드 타임 문제였다

Tailwind v4 다크 모드가 동작하지 않았던 이유: 상태 문제가 아니라 빌드 타임 문제였다

2026년 1월 29일

오답노트 서비스에서 다크 모드가 적용되지 않는 이슈가 발생했습니다. 상태는 바뀌고, 클래스도 붙는데, 화면은 그대로였습니다.

처음에는 상태 관리 문제라고 생각했지만, 문제는 React도, JS도 아닌 Tailwind의 동작 시점에 있었어요. 이 글은 Tailwind v4를 사용하면서 알게 된 것들을 정리한 글입니다.

문제 상황

오답노트 서비스에서는 라이트 / 다크 / 시스템 설정 모드로 사용자가 테마를 선택할 수 있다. 그런데 모드를 변경해도 테마가 변경되지 않는 이슈가 발생했다.

해당 이슈를 살펴보면 여러 가정을 해볼 수 있는데, 먼저 UI 상태가 안 바뀌어서 스타일이 반영되지 않는 ‘상태관리 문제’일 수 있다. 아니면 스타일 자체가 적용되지 않는 ‘스타일 시스템 문제’일 수 있다.

그리고 환경 역시 우려 중 한 몫을 차지했다. 현재 사용하고 있는 CSS 라이브러리는 Tailwind CSS v4로, v3에서 v4로 업데이트되면서 변경사항을 제대로 숙지하지 못 하고 있는 상태였다. 그래서 v4의 새로운 기능을 몰라서 발생한 이슈인가 싶기도 했다.

내가 처음 한 생각

먼저 구현 상태를 먼저 살펴보면, 유저가 테마를 선택하면 상태가 변경되고, applyTheme 함수를 통해 테마가 적용된다.

function applyTheme(selectedTheme: "light" | "dark" | "system") {
  const root = document.documentElement;
  const isDark =
    selectedTheme === "dark" ||
    (selectedTheme === "system" &&
      window.matchMedia("(prefers-color-scheme: dark)").matches);

  root.classList.toggle("dark", isDark);
}

UI 역시 상태를 변경하면, 선택된 테마에 따라 UI가 변경되었기 때문에 상태 관리 문제는 아니라고 판단했다.

실제로 해 본 선택

또다른 가설로는 “.dark 클래스 미적용”이었다. 그래서 DevTools에서 태그에 클래스 확인을 하였다. 그 결과 .dark 클래스가 정상적으로 추가되어 있었다.

따라서 Tailwind v4 다크 모드 설정 문제일 가능성이 높다고 판단했다.

Tailwind CSS v4에서 클래스 기반 다크 모드를 사용하려면 @custom-variant를 사용해 직접 정의해야 한다. (Tailwind v4 공식 문서)

@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
  • dark: 변형이 .dark 클래스를 가진 요소와 그 자식 요소들에 적용되도록 설정
  • :where() 선택자를 사용해 명시도(specificity)를 0으로 유지

Tailwind는 런타임에서 상태를 보고 스타일을 바꾸는 도구가 아니라, 빌드 타임에 쓸 수 있는 스타일만 미리 생성하는 CSS 생성기다.

개발 중 Tailwind는

  • JSX/HTML 안의 className 문자열을 스캔해
  • 실제로 사용된 클래스에 해당하는 CSS만 만들어 최종 번들에 포함시킨다.

실행 중에 DOM에 클래스를 추가하거나 제거하는 것은 Tailwind 입장에서는 이미 만들어진 CSS 중 무엇을 쓰느냐의 문제일 뿐, 새로운 스타일을 만들어주지는 않는다.

이 때문에 dark: 같은 variant는 단순한 조건 옵션이 아니다. dark:text-white를 쓰면 Tailwind는 “dark variant가 정의되어 있다면 그에 해당하는 CSS를 만들어야 한다”고 판단한다. variant가 정의되어 있지 않으면, 해당 클래스는 아예 CSS가 생성되지 않는다. 이 경우 DOM에 .dark 클래스가 붙어 있어도, JS에서 테마를 토글해도 적용될 스타일 자체가 존재하지 않는 상태가 된다.

Tailwind v3까지는 dark variant가 기본으로 제공되었기 때문에 이런 구조를 의식하지 않아도 됐다. 하지만 v4부터는 variant를 명시적으로 정의한 것만 사용하는 방향으로 바뀌었고, 따라서 다크 모드를 쓰려면 @custom-variant dark (&:where(.dark, .dark *));처럼 직접 정의해줘야 한다. 이를 하지 않으면 dark:가 붙은 클래스들은 컴파일 단계에서 무시되고, 결과 CSS에도 포함되지 않는다.

plugin, variant, custom-variant의 공통점은 모두 빌드 타임에 어떤 CSS를 만들 것인지를 결정하는 설계 도구라는 점이다. 정의되지 않은 variant는 “조건이 안 맞아서 안 보이는 상태”가 아니라, 처음부터 스타일이 없는 상태다. 이 감각을 가지면 다크 모드 문제를 JS 상태나 React 로직이 아니라, “이 클래스에 대응하는 CSS가 빌드 결과물에 실제로 존재하는가?”라는 관점에서 판단할 수 있게 된다.

지금 다시 해본다면

앞으로 비슷한 상황에 놓인다면

  • 상태가 바뀌는가? JS
  • 클래스가 추가되는가? CSS
  • 그 클래스에 대응하는 CSS가 실제로 생성되었는가? 빌드

이런 식으로 사고 과정을 거쳐야 겠다.

비고

  • Tailwind CSS v3 → v4 주요 변경사항

    1. 설정 파일 → CSS로 이동: tailwind.config.js의 많은 설정이 CSS 내 @theme, @custom-variant 등으로 이동
    2. 다크 모드 기본값 변경: class 기반에서 media 기반으로 변경됨
    3. 명시적 선언 필요: 클래스 기반 다크 모드를 원한다면 반드시 @custom-variant로 선언
  • 명시도 vs 우선순위

    • 명시도
      • CSS 선택자의 구체성(specificity)을 의미
      • 우선순위 중 가장 강력한 규칙 하나
    • 우선순위
      • 여러 규칙을 다 적용한 뒤 결정되는 결과
      • CSS 규칙이 충돌할 때 어떤 것이 적용될지를 결정하는 기준
  • CSS 판단 흐름

    1. !important
    2. 명시도가 높은 쪽
    3. 명시도가 같다면, 나중에 선언된 쪽 => 이 세 가지를 합한 결과가 우선순위
  • :where()를 사용하는 이유

    • .dark .dark\:text-white { color: white; }와 같은 클래스가 생성되지 않도록 하기 위함
      • 다크모드가 조건일 뿐인데, 명시도가 올라가서 기본 클래스들을 무조건 이겨버림
    • :where() 안에 들어간 선택자는 무조건 명시도 0점

실제 변경 내역