junyeokk
Blog
React Ecosystem·2025. 11. 15

next-themes

웹 애플리케이션에 다크모드를 추가하는 건 겉보기엔 간단해 보인다. CSS 변수 몇 개 바꾸고, 토글 버튼 하나 달면 끝이라고 생각하기 쉽다. 그런데 실제로 구현하다 보면 예상치 못한 문제들이 쏟아진다.

가장 먼저 부딪히는 문제가 FOUC(Flash of Unstyled Content)다. SSR이나 SSG로 빌드된 페이지가 로드될 때, 서버에서는 사용자가 어떤 테마를 선호하는지 알 수 없다. 서버는 무조건 기본 테마(보통 라이트)로 HTML을 렌더링하고, 클라이언트 JavaScript가 실행된 후에야 localStorage에서 저장된 테마를 읽어 적용한다. 그 사이 짧은 순간에 라이트 테마가 번쩍 보였다가 다크로 전환되는 현상이 생긴다. 사용자 경험을 심각하게 해치는 문제다.

두 번째 문제는 시스템 테마 동기화다. OS에서 다크모드를 설정한 사용자에게는 별도의 토글 없이도 다크모드가 적용되어야 한다. prefers-color-scheme 미디어 쿼리를 감지하고, 실시간으로 변경을 추적해야 한다.

세 번째는 상태 관리의 복잡성이다. 테마 설정을 localStorage에 저장하고, 여러 탭에서 동기화하고, React 상태와 DOM 속성을 모두 일관되게 유지해야 한다.

next-themes는 이 모든 문제를 해결하기 위해 만들어진 라이브러리다. Next.js에 특화되어 있지만, 핵심 아이디어는 모든 SSR 프레임워크에 적용 가능한 패턴이다.

FOUC를 막는 핵심 원리: 인라인 스크립트 주입

next-themes가 FOUC를 방지하는 방법은 우아하면서도 단순하다. React 하이드레이션이 시작되기 전에, 블로킹 인라인 스크립트로 테마를 적용하는 것이다.

브라우저는 HTML을 파싱하면서 <script> 태그를 만나면 그 즉시 실행한다(defer/async가 아닌 경우). next-themes는 이 특성을 이용해서, <body> 안 최상단에 인라인 스크립트를 주입한다. 이 스크립트는:

  1. localStorage에서 저장된 테마를 읽는다
  2. 저장된 테마가 없으면 시스템 테마를 감지한다
  3. <html> 요소에 즉시 테마 속성을 적용한다
html
<script>
  // next-themes가 주입하는 스크립트의 단순화된 버전
  (function() {
    var theme = localStorage.getItem('theme');
    
    if (!theme) {
      // 시스템 테마 감지
      theme = window.matchMedia('(prefers-color-scheme: dark)').matches 
        ? 'dark' 
        : 'light';
    }
    
    document.documentElement.setAttribute('data-theme', theme);
    // 또는 class 모드인 경우
    // document.documentElement.classList.add(theme);
  })();
</script>

이 스크립트는 React가 로드되기 전에 실행되므로, 사용자가 페이지를 볼 때는 이미 올바른 테마가 적용된 상태다. 브라우저의 첫 페인트 시점에 이미 올바른 CSS가 적용되어 있기 때문에 깜박임이 없다.

이게 왜 중요한지 useEffect로 테마를 적용하는 일반적인 방식과 비교해보자:

text
[서버 렌더링] → [HTML 파싱] → [첫 페인트: 라이트 테마] → [JS 로드] → [React 하이드레이션] → [useEffect 실행] → [테마 변경: 다크]
                                    ↑ 여기서 깜박임 발생!

[서버 렌더링] → [HTML 파싱] → [인라인 스크립트: 테마 적용] → [첫 페인트: 다크 테마] → [JS 로드] → [React 하이드레이션]
                                                                  ↑ 깜박임 없음!

기본 사용법

Provider 설정

Next.js App Router에서의 설정:

tsx
// app/layout.tsx
import { ThemeProvider } from 'next-themes';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html suppressHydrationWarning>
      <head />
      <body>
        <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

suppressHydrationWarning<html> 태그에 반드시 넣어야 한다. next-themes의 인라인 스크립트가 서버 렌더링된 HTML과 다른 속성을 <html>에 적용하기 때문에, React가 하이드레이션 불일치 경고를 띄운다. 이 prop은 해당 요소 한 단계에만 적용되므로, 자식 요소의 하이드레이션 경고는 정상적으로 표시된다.

Pages Router에서는 _app.tsx에서 감싸면 된다:

tsx
// pages/_app.tsx
import { ThemeProvider } from 'next-themes';
import type { AppProps } from 'next/app';

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ThemeProvider attribute="class">
      <Component {...pageProps} />
    </ThemeProvider>
  );
}

export default MyApp;

useTheme 훅

테마를 읽거나 변경할 때 사용한다:

tsx
import { useTheme } from 'next-themes';

function ThemeToggle() {
  const { theme, setTheme, resolvedTheme, systemTheme } = useTheme();

  return (
    <button onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}>
      현재 테마: {resolvedTheme}
    </button>
  );
}

useTheme이 반환하는 값들의 차이를 이해하는 게 중요하다:

속성설명
theme현재 설정된 테마 이름. "system"일 수 있다
setTheme테마를 변경하는 함수. useState의 setter와 동일한 API
resolvedTheme실제 적용된 테마. theme"system"이면 "dark" 또는 "light"로 해석된 값
systemThemeOS 설정 테마. theme 값과 무관하게 항상 시스템 설정을 반영
themes사용 가능한 테마 목록
forcedTheme강제 적용된 테마. 있으면 테마 변경 UI를 비활성화해야 함

themeresolvedTheme의 차이가 핵심이다. 사용자가 "시스템 설정을 따르겠다"고 선택한 경우 theme"system"이다. 하지만 실제로 화면에 보이는 건 다크 아니면 라이트다. 이때 resolvedTheme이 그 실제 값을 알려준다.

하이드레이션 불일치 방지 패턴

서버에서는 테마를 알 수 없기 때문에, useTheme의 반환값은 서버에서 undefined다. 테마에 따라 다른 UI를 보여주는 컴포넌트는 클라이언트에서 마운트된 후에만 렌더링해야 한다:

tsx
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';

function ThemeToggle() {
  const [mounted, setMounted] = useState(false);
  const { resolvedTheme, setTheme } = useTheme();

  useEffect(() => {
    setMounted(true);
  }, []);

  // 마운트 전에는 스켈레톤이나 빈 공간을 보여준다
  if (!mounted) {
    return <div style={{ width: 32, height: 32 }} />;
  }

  return (
    <button onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}>
      {resolvedTheme === 'dark' ? '🌙' : '☀️'}
    </button>
  );
}

이 패턴이 필요한 이유: 서버에서 resolvedThemeundefined이고, 클라이언트에서는 "dark" 같은 실제 값이다. 이 불일치가 하이드레이션 에러를 발생시킨다. mounted 상태로 감싸면, 서버와 클라이언트 모두 처음에는 같은 플레이스홀더를 렌더링하고, 마운트 후에만 테마 기반 UI를 표시한다.

중요한 점은 이 패턴이 필요한 건 테마에 따라 다른 컴포넌트를 렌더링하는 경우뿐이라는 것이다. CSS로 다크/라이트를 구분하는 건 인라인 스크립트가 처리해주므로 문제없다. mounted 가드가 필요한 건 JavaScript 로직에서 theme 값을 분기에 사용할 때다.

CSS 연동 방식

next-themes 자체는 CSS에 독립적이다. 스타일링 방식을 강제하지 않고, HTML 속성만 변경한다. CSS 측에서 그 속성을 어떻게 활용하느냐는 개발자의 선택이다.

data 속성 방식 (기본값)

tsx
<ThemeProvider attribute="data-theme">
css
:root {
  --background: #ffffff;
  --foreground: #000000;
}

[data-theme='dark'] {
  --background: #0a0a0a;
  --foreground: #ededed;
}

body {
  background: var(--background);
  color: var(--foreground);
}

data-theme 속성이 <html> 요소에 설정되므로, CSS 선택자로 바로 사용할 수 있다. 이 방식은 CSS 변수와 궁합이 좋다. 루트에서 변수를 정의하고, 테마별 선택자에서 같은 변수를 덮어쓰면 된다.

class 방식 (Tailwind CSS)

Tailwind CSS의 다크모드는 dark 클래스를 기반으로 동작한다:

tsx
<ThemeProvider attribute="class">
js
// tailwind.config.js
module.exports = {
  darkMode: 'class',
  // ...
};

이렇게 설정하면 다크모드일 때 <html class="dark">가 적용되고, Tailwind의 dark: 변형을 자유롭게 사용할 수 있다:

tsx
<div className="bg-white dark:bg-gray-900 text-black dark:text-white">
  다크모드에서 자동으로 스타일 변경
</div>

CSS 변수 없이도 동작

CSS 변수가 필수는 아니다. 하드코딩된 값도 정상 동작한다:

css
body {
  color: #000;
  background: #fff;
}

[data-theme='dark'] body {
  color: #fff;
  background: #000;
}

인라인 스크립트가 첫 페인트 전에 속성을 설정하므로, CSS 변수든 하드코딩이든 깜박임 없이 적용된다.

ThemeProvider 주요 설정

attribute

테마가 적용되는 HTML 속성을 결정한다. 기본값은 "data-theme".

tsx
// data 속성 (기본)
<ThemeProvider attribute="data-theme">    // → <html data-theme="dark">
<ThemeProvider attribute="data-mode">     // → <html data-mode="dark">
<ThemeProvider attribute="data-color">    // → <html data-color="dark">

// class 속성 (Tailwind용)
<ThemeProvider attribute="class">         // → <html class="dark">

enableSystem

시스템 테마 감지 여부. 기본값 true.

true일 때, prefers-color-scheme 미디어 쿼리를 실시간으로 감지한다. 사용자가 OS 설정에서 다크모드를 토글하면, theme"system"인 경우 즉시 반영된다.

tsx
// 시스템 테마 감지 비활성화 - light/dark 수동 선택만 허용
<ThemeProvider enableSystem={false}>

enableColorScheme

true(기본값)이면, 브라우저의 내장 UI 요소(스크롤바, 입력 필드 등)에도 테마를 적용한다. <html> 요소에 color-scheme: dark 또는 color-scheme: light를 설정하는데, 이것 때문에 브라우저가 자체적으로 다크 스타일을 적용한다.

tsx
// 브라우저 내장 UI 테마 적용 비활성화
<ThemeProvider enableColorScheme={false}>

defaultTheme

기본 테마. 기본값은 "system".

localStorage에 저장된 테마가 없을 때 적용되는 초기 테마다. "system"이면 OS 설정을 따르고, "light" 또는 "dark"로 지정하면 해당 테마가 초기값이 된다.

themes

사용 가능한 테마 목록. 기본값은 ['light', 'dark'].

2개 이상의 커스텀 테마를 사용할 때 지정한다:

tsx
<ThemeProvider themes={['light', 'dark', 'sepia', 'ocean']}>

주의할 점은 themes를 직접 설정하면 기본 ['light', 'dark']덮어씌워진다는 것이다. 라이트/다크도 유지하려면 명시적으로 포함해야 한다.

value

테마 이름과 DOM 속성 값을 다르게 매핑할 때 사용한다:

tsx
<ThemeProvider 
  attribute="class" 
  value={{ 
    light: 'theme-light', 
    dark: 'theme-dark' 
  }}
>

이렇게 하면 setTheme('dark') 호출 시 <html class="theme-dark">가 된다. localStorage에는 "dark"가 저장되고, DOM에만 다른 값이 적용된다. 기존 CSS 클래스 네이밍 규칙과 맞출 때 유용하다.

forcedTheme

특정 페이지에 테마를 강제 적용한다. 마케팅 페이지처럼 항상 다크모드여야 하는 페이지에 사용한다:

tsx
// Pages Router에서의 강제 테마
function MarketingPage() {
  return <div>항상 다크 테마</div>;
}
MarketingPage.theme = 'dark';
export default MarketingPage;

// _app.tsx
function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ThemeProvider forcedTheme={Component.theme || undefined}>
      <Component {...pageProps} />
    </ThemeProvider>
  );
}

App Router에서는 페이지별로 다른 ThemeProvider를 감싸거나, 클라이언트 컴포넌트에서 forcedTheme을 동적으로 전달하는 방식을 사용한다.

강제 테마가 적용된 상태에서 setTheme을 호출해도 아무 일도 일어나지 않는다. forcedTheme이 truthy이면 테마 변경 UI를 비활성화하는 게 좋다.

disableTransitionOnChange

true로 설정하면 테마 전환 시 모든 CSS 트랜지션을 일시적으로 비활성화한다:

tsx
<ThemeProvider disableTransitionOnChange>

이 옵션이 왜 필요한지 생각해보자. 버튼에 transition: background-color 200ms, 카드에 transition: all 300ms처럼 서로 다른 트랜지션이 적용된 UI에서 테마를 바꾸면, 각 요소가 제각기 다른 속도로 색상이 변하면서 어색하게 보인다.

내부적으로는 테마 변경 직전에 * { transition: none !important } 스타일을 삽입하고, 변경 후 즉시 제거하는 방식이다. getComputedStyle을 한 번 호출해서 스타일 적용을 강제한 후 제거하므로, 사용자 눈에는 테마가 순간적으로 바뀌는 것처럼 보인다.

nonce

CSP(Content Security Policy)를 사용하는 환경에서, 인라인 스크립트에 nonce를 전달해야 할 때:

tsx
<ThemeProvider nonce="abc123">

CSP에서 script-src 'nonce-abc123'을 설정하면, next-themes가 주입하는 인라인 스크립트도 정상 실행된다.

시스템 테마 감지 메커니즘

next-themes는 window.matchMedia('(prefers-color-scheme: dark)')를 사용해서 시스템 테마를 감지한다. 단순히 한 번 읽는 게 아니라, 이벤트 리스너로 실시간 변경을 추적한다:

typescript
// 단순화된 내부 동작
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

// 초기 감지
const systemTheme = mediaQuery.matches ? 'dark' : 'light';

// 실시간 변경 추적
mediaQuery.addEventListener('change', (e) => {
  const newSystemTheme = e.matches ? 'dark' : 'light';
  // theme이 'system'이면 즉시 반영
});

OS에서 다크모드를 켜거나 끄면, 브라우저가 change 이벤트를 발생시키고, next-themes가 이를 감지해서 resolvedTheme을 업데이트한다.

탭 간 동기화

next-themes는 window.addEventListener('storage', ...)로 localStorage 변경을 감지한다. 한 탭에서 테마를 바꾸면, 다른 탭에서도 즉시 반영된다:

typescript
// 단순화된 내부 동작
window.addEventListener('storage', (e) => {
  if (e.key === 'theme') {
    // 다른 탭에서 테마가 바뀜
    applyTheme(e.newValue);
  }
});

storage 이벤트는 같은 탭에서는 발생하지 않고, 다른 탭에서 변경되었을 때만 발생한다. 이 특성 덕분에 무한 루프 없이 자연스럽게 동기화된다.

커스텀 테마 확장

라이트/다크를 넘어서 여러 테마를 지원하는 것도 간단하다:

tsx
<ThemeProvider themes={['light', 'dark', 'sepia', 'ocean', 'forest']}>
css
:root {
  --bg: #ffffff;
  --text: #000000;
  --accent: #0070f3;
}

[data-theme='dark'] {
  --bg: #0a0a0a;
  --text: #ededed;
  --accent: #3291ff;
}

[data-theme='sepia'] {
  --bg: #f4ecd8;
  --text: #5c4b37;
  --accent: #8b6914;
}

[data-theme='ocean'] {
  --bg: #0d1b2a;
  --text: #e0e1dd;
  --accent: #00b4d8;
}

[data-theme='forest'] {
  --bg: #1a2f1a;
  --text: #d4e6d4;
  --accent: #4caf50;
}
tsx
function ThemeSelector() {
  const { theme, setTheme, themes } = useTheme();

  return (
    <select value={theme} onChange={(e) => setTheme(e.target.value)}>
      {themes.map((t) => (
        <option key={t} value={t}>{t}</option>
      ))}
    </select>
  );
}

커스텀 테마를 추가할 때 enableSystemtrue(기본값)이면, themes 배열에 "system"이 자동으로 추가된다. 사용자가 커스텀 테마 중 하나를 선택하면 그 테마가 적용되고, "system"을 선택하면 OS 설정으로 돌아간다.

다른 접근법과의 비교

CSS만으로 다크모드 구현

css
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #000;
    --text: #fff;
  }
}

CSS만으로도 시스템 테마를 따르는 다크모드는 구현할 수 있다. 하지만 사용자가 직접 테마를 선택하는 토글 기능을 CSS만으로는 불가능하다. 사용자가 시스템은 라이트모드인데 사이트에서만 다크모드를 쓰고 싶다면? JavaScript가 필요하다.

useEffect + localStorage 직접 구현

tsx
function useTheme() {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    const saved = localStorage.getItem('theme');
    if (saved) {
      setTheme(saved);
      document.documentElement.setAttribute('data-theme', saved);
    }
  }, []);

  const changeTheme = (newTheme: string) => {
    setTheme(newTheme);
    localStorage.setItem('theme', newTheme);
    document.documentElement.setAttribute('data-theme', newTheme);
  };

  return { theme, setTheme: changeTheme };
}

이 방식의 치명적 단점이 FOUC다. useEffect는 컴포넌트가 마운트된 후에 실행되므로, 첫 페인트에서 깜박임이 발생한다. next-themes의 인라인 스크립트 주입은 이 문제를 우아하게 해결한다.

또한 직접 구현하면 시스템 테마 감지, 탭 간 동기화, 강제 테마, 하이드레이션 불일치 처리 등을 전부 수동으로 해야 한다. 생각보다 엣지 케이스가 많다.

다른 라이브러리 비교

기능next-themes직접 구현CSS only
FOUC 방지✅ 인라인 스크립트
사용자 토글
시스템 테마 감지수동 구현
탭 간 동기화수동 구현N/A
강제 테마수동 구현
커스텀 테마 (3+)
SSR/SSG 지원직접 처리
Next.js App Router직접 처리

실전 팁

Tailwind + next-themes 조합

가장 흔한 조합이다. 설정이 간결하고, Tailwind의 dark: 변형을 그대로 활용할 수 있다:

tsx
// layout.tsx
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
  {children}
</ThemeProvider>
js
// tailwind.config.js
module.exports = {
  darkMode: 'class',
};
tsx
// 컴포넌트에서 바로 사용
<div className="bg-white dark:bg-slate-900 transition-colors">
  <p className="text-gray-900 dark:text-gray-100">
    자동으로 테마에 맞는 스타일이 적용된다
  </p>
</div>

CSS 변수 + Tailwind 하이브리드

CSS 변수로 테마 색상을 정의하고, Tailwind에서 참조하는 방식도 강력하다:

css
/* globals.css */
:root {
  --background: 0 0% 100%;
  --foreground: 222 47% 11%;
  --primary: 221 83% 53%;
}

.dark {
  --background: 222 47% 4%;
  --foreground: 210 40% 98%;
  --primary: 217 91% 60%;
}
js
// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
        primary: 'hsl(var(--primary))',
      },
    },
  },
};

이 패턴은 shadcn/ui에서도 사용하는 방식이다. CSS 변수로 테마를 정의하면, Tailwind 클래스에서 bg-background, text-foreground 같은 시맨틱한 이름으로 사용할 수 있다.

storageKey 커스터마이징

여러 앱이 같은 도메인에서 동작하거나, localStorage 키 충돌을 피하고 싶을 때:

tsx
<ThemeProvider storageKey="my-app-theme">

기본값은 "theme"이므로, 같은 도메인의 다른 앱과 충돌할 수 있다. 앱별로 고유한 키를 설정하면 안전하다.


정리

  • FOUC 방지의 핵심은 React 하이드레이션 전에 블로킹 인라인 스크립트로 테마를 적용하는 것이다. useEffect 방식으로는 구조적으로 해결할 수 없다.
  • theme vs resolvedTheme 구분이 중요하다. theme"system"일 수 있지만, 실제 화면에 적용된 테마는 resolvedTheme으로 확인해야 한다.
  • CSS 변수 + Tailwind 하이브리드 패턴이 가장 유연하다. 시맨틱 토큰으로 색상을 관리하면 커스텀 테마 확장도 깔끔하게 처리된다.

관련 문서