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> 안 최상단에 인라인 스크립트를 주입한다. 이 스크립트는:
- localStorage에서 저장된 테마를 읽는다
- 저장된 테마가 없으면 시스템 테마를 감지한다
<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로 테마를 적용하는 일반적인 방식과 비교해보자:
[서버 렌더링] → [HTML 파싱] → [첫 페인트: 라이트 테마] → [JS 로드] → [React 하이드레이션] → [useEffect 실행] → [테마 변경: 다크]
↑ 여기서 깜박임 발생!
[서버 렌더링] → [HTML 파싱] → [인라인 스크립트: 테마 적용] → [첫 페인트: 다크 테마] → [JS 로드] → [React 하이드레이션]
↑ 깜박임 없음!
기본 사용법
Provider 설정
Next.js App Router에서의 설정:
// 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에서 감싸면 된다:
// 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 훅
테마를 읽거나 변경할 때 사용한다:
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"로 해석된 값 |
systemTheme | OS 설정 테마. theme 값과 무관하게 항상 시스템 설정을 반영 |
themes | 사용 가능한 테마 목록 |
forcedTheme | 강제 적용된 테마. 있으면 테마 변경 UI를 비활성화해야 함 |
theme과 resolvedTheme의 차이가 핵심이다. 사용자가 "시스템 설정을 따르겠다"고 선택한 경우 theme은 "system"이다. 하지만 실제로 화면에 보이는 건 다크 아니면 라이트다. 이때 resolvedTheme이 그 실제 값을 알려준다.
하이드레이션 불일치 방지 패턴
서버에서는 테마를 알 수 없기 때문에, useTheme의 반환값은 서버에서 undefined다. 테마에 따라 다른 UI를 보여주는 컴포넌트는 클라이언트에서 마운트된 후에만 렌더링해야 한다:
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>
);
}
이 패턴이 필요한 이유: 서버에서 resolvedTheme은 undefined이고, 클라이언트에서는 "dark" 같은 실제 값이다. 이 불일치가 하이드레이션 에러를 발생시킨다. mounted 상태로 감싸면, 서버와 클라이언트 모두 처음에는 같은 플레이스홀더를 렌더링하고, 마운트 후에만 테마 기반 UI를 표시한다.
중요한 점은 이 패턴이 필요한 건 테마에 따라 다른 컴포넌트를 렌더링하는 경우뿐이라는 것이다. CSS로 다크/라이트를 구분하는 건 인라인 스크립트가 처리해주므로 문제없다. mounted 가드가 필요한 건 JavaScript 로직에서 theme 값을 분기에 사용할 때다.
CSS 연동 방식
next-themes 자체는 CSS에 독립적이다. 스타일링 방식을 강제하지 않고, HTML 속성만 변경한다. CSS 측에서 그 속성을 어떻게 활용하느냐는 개발자의 선택이다.
data 속성 방식 (기본값)
<ThemeProvider attribute="data-theme">
: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 클래스를 기반으로 동작한다:
<ThemeProvider attribute="class">
// tailwind.config.js
module.exports = {
darkMode: 'class',
// ...
};
이렇게 설정하면 다크모드일 때 <html class="dark">가 적용되고, Tailwind의 dark: 변형을 자유롭게 사용할 수 있다:
<div className="bg-white dark:bg-gray-900 text-black dark:text-white">
다크모드에서 자동으로 스타일 변경
</div>
CSS 변수 없이도 동작
CSS 변수가 필수는 아니다. 하드코딩된 값도 정상 동작한다:
body {
color: #000;
background: #fff;
}
[data-theme='dark'] body {
color: #fff;
background: #000;
}
인라인 스크립트가 첫 페인트 전에 속성을 설정하므로, CSS 변수든 하드코딩이든 깜박임 없이 적용된다.
ThemeProvider 주요 설정
attribute
테마가 적용되는 HTML 속성을 결정한다. 기본값은 "data-theme".
// 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"인 경우 즉시 반영된다.
// 시스템 테마 감지 비활성화 - light/dark 수동 선택만 허용
<ThemeProvider enableSystem={false}>
enableColorScheme
true(기본값)이면, 브라우저의 내장 UI 요소(스크롤바, 입력 필드 등)에도 테마를 적용한다. <html> 요소에 color-scheme: dark 또는 color-scheme: light를 설정하는데, 이것 때문에 브라우저가 자체적으로 다크 스타일을 적용한다.
// 브라우저 내장 UI 테마 적용 비활성화
<ThemeProvider enableColorScheme={false}>
defaultTheme
기본 테마. 기본값은 "system".
localStorage에 저장된 테마가 없을 때 적용되는 초기 테마다. "system"이면 OS 설정을 따르고, "light" 또는 "dark"로 지정하면 해당 테마가 초기값이 된다.
themes
사용 가능한 테마 목록. 기본값은 ['light', 'dark'].
2개 이상의 커스텀 테마를 사용할 때 지정한다:
<ThemeProvider themes={['light', 'dark', 'sepia', 'ocean']}>
주의할 점은 themes를 직접 설정하면 기본 ['light', 'dark']가 덮어씌워진다는 것이다. 라이트/다크도 유지하려면 명시적으로 포함해야 한다.
value
테마 이름과 DOM 속성 값을 다르게 매핑할 때 사용한다:
<ThemeProvider
attribute="class"
value={{
light: 'theme-light',
dark: 'theme-dark'
}}
>
이렇게 하면 setTheme('dark') 호출 시 <html class="theme-dark">가 된다. localStorage에는 "dark"가 저장되고, DOM에만 다른 값이 적용된다. 기존 CSS 클래스 네이밍 규칙과 맞출 때 유용하다.
forcedTheme
특정 페이지에 테마를 강제 적용한다. 마케팅 페이지처럼 항상 다크모드여야 하는 페이지에 사용한다:
// 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 트랜지션을 일시적으로 비활성화한다:
<ThemeProvider disableTransitionOnChange>
이 옵션이 왜 필요한지 생각해보자. 버튼에 transition: background-color 200ms, 카드에 transition: all 300ms처럼 서로 다른 트랜지션이 적용된 UI에서 테마를 바꾸면, 각 요소가 제각기 다른 속도로 색상이 변하면서 어색하게 보인다.
내부적으로는 테마 변경 직전에 * { transition: none !important } 스타일을 삽입하고, 변경 후 즉시 제거하는 방식이다. getComputedStyle을 한 번 호출해서 스타일 적용을 강제한 후 제거하므로, 사용자 눈에는 테마가 순간적으로 바뀌는 것처럼 보인다.
nonce
CSP(Content Security Policy)를 사용하는 환경에서, 인라인 스크립트에 nonce를 전달해야 할 때:
<ThemeProvider nonce="abc123">
CSP에서 script-src 'nonce-abc123'을 설정하면, next-themes가 주입하는 인라인 스크립트도 정상 실행된다.
시스템 테마 감지 메커니즘
next-themes는 window.matchMedia('(prefers-color-scheme: dark)')를 사용해서 시스템 테마를 감지한다. 단순히 한 번 읽는 게 아니라, 이벤트 리스너로 실시간 변경을 추적한다:
// 단순화된 내부 동작
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 변경을 감지한다. 한 탭에서 테마를 바꾸면, 다른 탭에서도 즉시 반영된다:
// 단순화된 내부 동작
window.addEventListener('storage', (e) => {
if (e.key === 'theme') {
// 다른 탭에서 테마가 바뀜
applyTheme(e.newValue);
}
});
storage 이벤트는 같은 탭에서는 발생하지 않고, 다른 탭에서 변경되었을 때만 발생한다. 이 특성 덕분에 무한 루프 없이 자연스럽게 동기화된다.
커스텀 테마 확장
라이트/다크를 넘어서 여러 테마를 지원하는 것도 간단하다:
<ThemeProvider themes={['light', 'dark', 'sepia', 'ocean', 'forest']}>
: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;
}
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>
);
}
커스텀 테마를 추가할 때 enableSystem이 true(기본값)이면, themes 배열에 "system"이 자동으로 추가된다. 사용자가 커스텀 테마 중 하나를 선택하면 그 테마가 적용되고, "system"을 선택하면 OS 설정으로 돌아간다.
다른 접근법과의 비교
CSS만으로 다크모드 구현
@media (prefers-color-scheme: dark) {
:root {
--bg: #000;
--text: #fff;
}
}
CSS만으로도 시스템 테마를 따르는 다크모드는 구현할 수 있다. 하지만 사용자가 직접 테마를 선택하는 토글 기능을 CSS만으로는 불가능하다. 사용자가 시스템은 라이트모드인데 사이트에서만 다크모드를 쓰고 싶다면? JavaScript가 필요하다.
useEffect + localStorage 직접 구현
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: 변형을 그대로 활용할 수 있다:
// layout.tsx
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
// tailwind.config.js
module.exports = {
darkMode: 'class',
};
// 컴포넌트에서 바로 사용
<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에서 참조하는 방식도 강력하다:
/* 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%;
}
// 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 키 충돌을 피하고 싶을 때:
<ThemeProvider storageKey="my-app-theme">
기본값은 "theme"이므로, 같은 도메인의 다른 앱과 충돌할 수 있다. 앱별로 고유한 키를 설정하면 안전하다.
정리
- FOUC 방지의 핵심은 React 하이드레이션 전에 블로킹 인라인 스크립트로 테마를 적용하는 것이다.
useEffect방식으로는 구조적으로 해결할 수 없다. themevsresolvedTheme구분이 중요하다.theme은"system"일 수 있지만, 실제 화면에 적용된 테마는resolvedTheme으로 확인해야 한다.- CSS 변수 + Tailwind 하이브리드 패턴이 가장 유연하다. 시맨틱 토큰으로 색상을 관리하면 커스텀 테마 확장도 깔끔하게 처리된다.