🌗 Next.js 다크 모드 완전 정복 — 깜빡임도 없고 경고도 없게

들어가며 — 교육생이 한 두 가지 질문
Q1. "CSS에서 dark/light 값을 둘 다 정의해 두고 CSS로 필터링하는 방식(바닐라처럼)"과
"next-themes 방식" 중 뭐가 제일 좋나요?Q2. hydration 이슈는
suppressHydrationWarning로 해결해 버리고 쓰던데, 그냥 무시해도 괜찮나요?
먼저 결론부터 정리하고, 그다음 직접 프로젝트를 만들며 한 단계씩 확인합니다.
Part 0. 두 질문에 대한 답
A1. 두 방식은 경쟁 관계가 아닙니다 — 역할이 다릅니다
다크 모드는 사실 두 개의 문제로 나뉩니다.
| 문제 | 담당 | 도구 |
|---|---|---|
| ① 색을 어떻게 정의할까 | CSS | CSS 변수, prefers-color-scheme, Tailwind dark: |
| ② 어떤 테마를 쓸지 누가 정하고 어떻게 기억할까 | 상태 관리 | 직접 만든 코드 또는 next-themes |
교육생이 말한 "CSS로 dark/light를 다 정의하고 필터링" 은 ①번 이야기입니다. 라이트값과 다크값을 둘 다 스타일시트에 적어 두고, <html> 의 클래스나 속성, 또는 미디어쿼리로 어느 쪽을 적용할지 고르는 것 — 이건 거의 모든 다크 모드 구현의 공통 토대입니다. Tailwind 의 dark: 도 내부적으로 이 방식이에요.
"next-themes" 는 ②번 이야기입니다. 사용자가 고른 테마를 localStorage 에 저장하고, OS 설정(prefers-color-scheme)과 연동하고, 페이지가 깜빡이지 않게 만들고, 여러 탭을 동기화하는 — 그 상태 관리를 대신 해 주는 라이브러리입니다.
즉 둘은 같이 씁니다. next-themes 가 <html data-theme="dark"> 를 설정하면, 우리가 ①번에서 만든 CSS 가 그 속성에 반응해 색을 바꿉니다.
그래서 "제일 좋은" 선택은?
| 상황 | 추천 |
|---|---|
| 토글 버튼 없이 OS 설정만 따라가면 됨 | 순수 CSS (prefers-color-scheme). JS 0줄, 깜빡임 0, 가장 단순. |
| 사용자가 직접 light/dark 를 고르는 토글이 필요함 | next-themes. 사실상 표준. |
next-themes 가 표준인 이유는, ②번을 직접 제대로 구현하기가 생각보다 까다롭기 때문입니다. 깜빡임 방지, OS 연동, 멀티탭 동기화, SSR 안전성을 전부 직접 처리하려면 코드가 늘고 버그가 생깁니다. next-themes 는 1KB 도 안 되는 크기로 이걸 다 해결합니다. 이 튜토리얼에서 직접 naive 버전을 만들어 보면 "왜 라이브러리를 쓰는지" 가 몸으로 이해됩니다.
A2. suppressHydrationWarning 은 "무시"가 아니라 "정밀한 조준"입니다
핵심부터: hydration 경고를 아무 데서나 끄면 안 됩니다. hydration mismatch 는 보통 진짜 버그(서버와 클라이언트가 다른 걸 그림)의 신호고, 무시하면 UI 가 깨지거나 이벤트 핸들러가 안 붙습니다.
그런데 다크 모드에는 피할 수 없고, 의도된, 무해한 mismatch 가 딱 하나 있습니다.
- 서버는 사용자의 테마를 모릅니다 (localStorage 는 브라우저에만 있음). 그래서
<html>을 테마 속성 없이 그립니다. - 깜빡임을 없애려면, 페인트 전에 도는 작은 스크립트가
<html>에data-theme="dark"를 일부러 붙입니다. - 그러면 React 가 하이드레이션할 때 "어, 서버 HTML 의
<html>엔data-theme가 없었는데 지금 DOM 엔 있네?" 하며 경고합니다.
이 mismatch 는 우리가 의도한 것이고 무해합니다. suppressHydrationWarning 는 바로 이 한 경우를 위한 도구예요.
그리고 결정적인 사실 — suppressHydrationWarning 은 그 요소 한 개의 속성에 대해서만, 한 단계 깊이로만 동작합니다. <html> 에 붙이면 <html> 자신의 속성 불일치만 조용해질 뿐, 그 안의 컴포넌트들에서 나는 진짜 hydration 버그는 그대로 경고가 뜹니다. 즉 "모든 경고를 끄는 스위치" 가 아니라 "이 한 요소만" 짚어 끄는 핀셋입니다.
정리:
<html>에 붙이는suppressHydrationWarning은 next-themes 공식 문서와 Next.js 가이드가 권장하는 정상적인 사용법입니다. "버그를 덮는 것" 이 아니라 "알려진 한 가지 의도된 불일치만 조준해서 끄는 것" 이에요. 다른 곳에서 hydration 경고가 나면 그건 여전히 진짜 버그로 보고 고쳐야 합니다.
이제 직접 만들면서 확인합니다.
STEP 1. 프로젝트 만들기
cd ~/devel # 작업 폴더 npx create-next-app@15.5.4 dark-mode-tutorial \ --typescript --no-tailwind --app \ --no-src-dir --no-turbopack --no-eslint \ --import-alias "@/*" --use-npm --yes cd dark-mode-tutorial
Tailwind 를 일부러 빼고 순수 CSS 로 갑니다. 다크 모드의 동작 원리가 가려지지 않고 그대로 보이게 하려는 의도예요.
포트를 3500 으로 고정합니다 (package.json 의 scripts):
"scripts": { "dev": "next dev -p 3500", "build": "next build", "start": "next start -p 3500" }
의존성 설치 + 나중에 쓸 next-themes 도 미리 설치:
npm install npm install next-themes
생성된 app/page.module.css 는 안 쓰므로 지웁니다:
rm app/page.module.css
STEP 2. CSS 변수로 두 테마를 "모두" 정의하기
교육생이 말한 그 방식입니다. 라이트값과 다크값을 둘 다 스타일시트에 적어 두고, 미디어쿼리로 고릅니다.
📄 app/globals.css 전체를 교체:
/* ── 라이트 모드: 기본값 ───────────────────────────── */ :root { --bg: #ffffff; --fg: #1a1a1a; --card: #f4f4f5; --border: #e4e4e7; --accent: #2563eb; } /* ── 다크 모드: OS 설정이 dark 면 자동 적용 ──────────── */ @media (prefers-color-scheme: dark) { :root { --bg: #18181b; --fg: #f4f4f5; --card: #27272a; --border: #3f3f46; --accent: #60a5fa; } } * { box-sizing: border-box; } html, body { margin: 0; padding: 0; } body { background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; transition: background 0.2s, color 0.2s; }
풀어 보기
🔹 CSS 변수 (--bg 등)
:root 에 정의한 변수는 페이지 어디서나 var(--bg) 로 꺼내 씁니다. 색을 한 군데서 관리할 수 있어요.
🔹 @media (prefers-color-scheme: dark)
브라우저/OS 가 "다크 모드 선호" 상태면 이 블록 안의 값이 :root 변수를 덮어씁니다. JavaScript 가 한 줄도 없습니다. CSS 만으로 OS 설정을 따라가요.
🔹 왜 깜빡임이 없나?
미디어쿼리는 브라우저가 HTML 을 받자마자, 첫 페인트 전에 평가합니다. 그래서 처음부터 올바른 색으로 그려집니다. SSR 과도 무관 — 서버가 무엇을 보내든 브라우저가 알아서 맞춥니다.
📄 app/layout.tsx 도 단순하게:
import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { title: "다크 모드 튜토리얼", description: "Next.js 다크 모드를 단계별로 익히기", }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="ko"> <body>{children}</body> </html> ); }
📄 app/page.tsx:
export default function HomePage() { return ( <main style={{ maxWidth: 640, margin: "0 auto", padding: "48px 24px" }}> <h1 style={{ fontSize: 32, fontWeight: 800 }}>🌗 다크 모드 튜토리얼</h1> <p style={{ opacity: 0.7 }}>지금은 OS 설정만 따라가는 순수 CSS 단계입니다.</p> <section style={{ marginTop: 24, padding: 20, background: "var(--card)", border: "1px solid var(--border)", borderRadius: 12, }} > <h2 style={{ marginTop: 0 }}>카드 예시</h2> <p>이 카드의 색은 모두 CSS 변수입니다. OS 다크 모드 설정을 바꾸면 자동으로 바뀝니다.</p> <button style={{ padding: "8px 16px", background: "var(--accent)", color: "#fff", border: "none", borderRadius: 8, cursor: "pointer", }} > 강조 버튼 </button> </section> </main> ); }
✅ 확인하기
npm run dev
http://localhost:3500 접속 후, OS 의 다크 모드 설정을 켰다 껐다 해 보세요 (맥: 시스템 설정 → 디스플레이, 윈도우: 설정 → 개인 설정 → 색).
- 페이지 배경/글자/카드 색이 OS 설정 따라 즉시 바뀝니다.
- JavaScript 0줄, 깜빡임 0, hydration 경고 0.
이게 "토글이 필요 없다면 가장 좋은 방법" 입니다. 하지만 사용자가 OS 와 무관하게 직접 고르고 싶다면? 다음 단계로.
STEP 3. 수동 토글 — 순진한(naive) 첫 시도
사용자가 직접 누르는 토글 버튼을 만듭니다. 먼저 CSS 가 "직접 고른 테마" 를 표현할 수 있어야 합니다.
📄 app/globals.css 의 @media 블록 바로 아래에 추가:
/* ── 사용자가 직접 고른 테마: data-theme 속성이 미디어쿼리를 이김 ── */ :root[data-theme="light"] { --bg: #ffffff; --fg: #1a1a1a; --card: #f4f4f5; --border: #e4e4e7; --accent: #2563eb; } :root[data-theme="dark"] { --bg: #18181b; --fg: #f4f4f5; --card: #27272a; --border: #3f3f46; --accent: #60a5fa; }
🔹 :root[data-theme="dark"] 는 :root 보다 선택자 우선순위(specificity)가 높습니다. 그래서 <html> 에 data-theme 가 붙으면 미디어쿼리를 이깁니다. "OS 설정보다 사용자의 명시적 선택이 우선" 이라는 자연스러운 규칙이 CSS 만으로 표현됩니다.
토글 컴포넌트 — 라이브 코딩
이제 버튼을 만듭니다. 한 번에 다 쓰지 말고, 껍데기 → 슈도코드 → 코드 순서로 채워 갑니다.
① 먼저 컴포넌트 껍데기와 할 일을 적습니다.
"use client"; import { useEffect, useState } from "react"; type Theme = "light" | "dark"; export default function ThemeToggleNaive() { // 1. 현재 테마를 state 로 들고 있는다 // 2. 마운트되면 localStorage 에서 저장된 테마를 읽어 적용한다 // 3. 버튼을 누르면 테마를 뒤집는다 return <button>...</button>; }
"use client"— useState/onClick 이 들어가니 클라이언트 컴포넌트입니다.
② 1번 슈도코드를 코드로 — state 추가:
export default function ThemeToggleNaive() { const [theme, setTheme] = useState<Theme>("light"); // 2. 마운트되면 localStorage 에서 저장된 테마를 읽어 적용한다 // 3. 버튼을 누르면 테마를 뒤집는다 return <button>...</button>; }
③ 2번 — 마운트 후 localStorage 읽기. 먼저 슈도코드:
useEffect(() => { // localStorage 에서 "theme" 키를 읽는다 (없으면 "light") // state 에 반영한다 // <html> 의 data-theme 속성에 반영한다 }, []);
④ 슈도코드를 코드로:
useEffect(() => { const saved = (localStorage.getItem("theme") as Theme) ?? "light"; setTheme(saved); document.documentElement.dataset.theme = saved; }, []);
document.documentElement가<html>입니다..dataset.theme = "dark"는<html data-theme="dark">와 같아요.
⑤ 3번 — 토글 함수. 먼저 슈도코드:
function toggleTheme() { // 반대 테마를 계산한다 // state 를 갱신한다 // <html> 을 갱신한다 // localStorage 에 저장한다 }
⑥ 슈도코드를 코드로:
function toggleTheme() { const next: Theme = theme === "light" ? "dark" : "light"; setTheme(next); document.documentElement.dataset.theme = next; localStorage.setItem("theme", next); }
⑦ 마지막으로 버튼이 함수를 호출하게 연결합니다. 완성된 파일:
📄 app/ThemeToggleNaive.tsx:
"use client"; import { useEffect, useState } from "react"; type Theme = "light" | "dark"; export default function ThemeToggleNaive() { const [theme, setTheme] = useState<Theme>("light"); useEffect(() => { const saved = (localStorage.getItem("theme") as Theme) ?? "light"; setTheme(saved); document.documentElement.dataset.theme = saved; }, []); function toggleTheme() { const next: Theme = theme === "light" ? "dark" : "light"; setTheme(next); document.documentElement.dataset.theme = next; localStorage.setItem("theme", next); } return ( <button onClick={toggleTheme} style={{ padding: "8px 16px", background: "var(--card)", color: "var(--fg)", border: "1px solid var(--border)", borderRadius: 8, cursor: "pointer", }} > 현재 테마: {theme} — 클릭해서 전환 </button> ); }
📄 app/page.tsx 에 토글을 끼웁니다. 파일 맨 위에 import 추가, <h1> 아래에 버튼 배치:
import ThemeToggleNaive from "./ThemeToggleNaive"; export default function HomePage() { return ( <main style={{ maxWidth: 640, margin: "0 auto", padding: "48px 24px" }}> <h1 style={{ fontSize: 32, fontWeight: 800 }}>🌗 다크 모드 튜토리얼</h1> <p style={{ opacity: 0.7 }}>수동 토글 단계 (STEP 3 — naive).</p> <div style={{ marginTop: 16 }}> <ThemeToggleNaive /> </div> {/* ...카드 section 은 그대로... */} </main> ); }
✅ 확인하기 — 그리고 문제 발견하기
http://localhost:3500 접속.
- 버튼을 누르면 테마가 바뀝니다. ✅
- 개발자도구 → Application → Local Storage 에
theme키가 저장됩니다. ✅ - 다크로 바꾼 뒤 새로고침(F5) 해 보세요. → 화면이 light 로 한 번 번쩍였다가 dark 로 바뀝니다. ❌ 이게 "깜빡임(flash)" 입니다.
왜 깜빡이나?
순서를 따라가 봅시다.
1. 서버가 HTML 을 만든다 → 서버는 localStorage 를 못 읽음 → <html> 에 data-theme 없음 2. 브라우저가 HTML 을 받아 그린다 → data-theme 없음 → 라이트(또는 OS) 색으로 첫 페인트 ← 여기서 light 가 보임 3. React 가 하이드레이션된다 4. useEffect 가 실행된다 → 그제서야 localStorage 읽고 data-theme="dark" 설정 → dark 로 다시 그림
2번과 4번 사이의 짧은 순간에 잘못된 테마가 보이는 것 — 이게 깜빡임입니다. useEffect 는 화면이 한 번 그려진 뒤에 실행되니까 구조적으로 피할 수 없어요.
E2E 로 확인된 사실: 다크를 저장하고 새로고침했을 때, 페인트 직전(
domcontentloaded) 시점에<html>의data-theme를 확인하면(none)입니다. 테마가 아직 안 정해진 채로 화면이 그려진다는 뜻이에요.
해결하려면 useEffect 보다 더 일찍 — 첫 페인트보다도 전에 — 테마를 적용해야 합니다.
STEP 4. 깜빡임 잡기 — 페인트 전에 도는 인라인 스크립트
useEffect 는 너무 늦습니다. 대신 <body> 가 그려지기 전에 실행되는 작은 <script> 를 HTML 에 직접 박습니다. 이게 바닐라 시절부터 쓰던 정석이고, next-themes 가 내부에서 하는 일이기도 합니다.
인라인 스크립트 — 라이브 코딩
① 이 스크립트가 할 일을 슈도코드로:
// (즉시 실행 함수) // localStorage 에서 "theme" 을 읽는다 // 그 값이 "dark" 또는 "light" 면 // <html> 의 data-theme 속성에 그 값을 넣는다 // localStorage 접근이 막혀 있어도(에러) 페이지가 죽지 않게 try/catch
② 슈도코드를 코드로:
(function () { try { var t = localStorage.getItem("theme"); if (t === "dark" || t === "light") { document.documentElement.dataset.theme = t; } } catch (e) {} })();
즉시 실행 함수
(function(){...})()로 감싼 이유: 이 스크립트가 만든 변수t가 전역을 더럽히지 않게 하기 위해서입니다.
③ 이 스크립트를 layout 의 <body> 맨 앞에 박습니다. <body> 의 첫 자식이면, 브라우저가 body 의 나머지 내용을 그리기 전에 이 스크립트를 실행합니다.
📄 app/layout.tsx:
import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { title: "다크 모드 튜토리얼", description: "Next.js 다크 모드를 단계별로 익히기", }; // 페인트 전에 실행되어 localStorage 의 테마를 <html> 에 즉시 반영하는 스크립트. const themeScript = `(function(){try{var t=localStorage.getItem('theme');if(t==='dark'||t==='light'){document.documentElement.dataset.theme=t;}}catch(e){}})();`; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="ko"> <body> <script dangerouslySetInnerHTML={{ __html: themeScript }} /> {children} </body> </html> ); }
dangerouslySetInnerHTML— 이름이 무섭지만, 우리가 직접 쓴 고정 문자열을 넣을 땐 안전합니다. React 에서<script>안에 코드 문자열을 넣는 표준 방법이에요.
✅ 확인하기 (1) — 깜빡임이 사라졌다
다크로 바꾸고 새로고침 → 이제 번쩍임 없이 처음부터 dark 입니다. ✅
E2E 로 확인된 사실: 페인트 직전(
domcontentloaded) 시점에<html>의data-theme가 이미dark입니다 (STEP 3 에서는(none)이었음). 인라인 스크립트가 페인트 전에 실행됐다는 증거예요.
✅ 확인하기 (2) — 그런데 콘솔에 경고가 떴다
개발자도구 → Console 을 열어 보세요. 다크를 저장한 채 새로고침하면 이런 경고가 뜹니다:
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties... <html lang="ko" - data-theme="dark" >
왜? 우리가 STEP 3 해설에서 본 그대로입니다.
- 서버는
<html lang="ko">를 그립니다 (data-theme 없음 — 서버는 테마를 모름). - 인라인 스크립트가 브라우저에서
<html>에data-theme="dark"를 붙입니다. - React 가 하이드레이션할 때, 서버가 보낸
<html>과 지금 DOM 의<html>이 다른 걸 보고 경고합니다.
이 mismatch 는 우리가 의도한 것입니다. 깜빡임을 없애려고 일부러 스크립트로 <html> 을 바꿨으니까요. 무해하지만, 경고가 계속 뜨면 진짜 버그를 놓치게 됩니다.
해결 — <html> 에만 suppressHydrationWarning
📄 app/layout.tsx 의 <html> 한 줄만 수정:
<html lang="ko" suppressHydrationWarning>
✅ 확인하기 (3)
다시 새로고침 → 경고가 사라졌습니다. ✅ 깜빡임도 여전히 없습니다.
E2E 로 확인된 사실:
suppressHydrationWarning추가 전 hydration 경고 2건 → 추가 후 0건. 깜빡임 방지(페인트 전data-theme="dark")는 그대로 유지.
중요: suppressHydrationWarning 은 <html> 그 요소 한 개의 속성 불일치만 끕니다. 페이지 안의 다른 컴포넌트에서 진짜 hydration 버그가 나면 그건 여전히 경고가 떠요. 그래서 안전합니다 — "알려진 한 가지" 만 조준해서 끈 것.
STEP 5. next-themes 로 교체하기
STEP 3 + STEP 4 로 우리는 직접 만들었습니다. 그런데 아직 빠진 게 많습니다.
- OS 설정 변경을 실시간 감지 (사용자가 "system" 을 골랐을 때)
- 여러 탭을 열어 두면 한 탭에서 바꾼 게 다른 탭에 반영
theme/resolvedTheme/systemTheme구분- 깜빡임 방지 스크립트의 세밀한 엣지 케이스
이걸 다 직접 하면 코드가 계속 늘어납니다. next-themes 가 정확히 이 일을 1KB 도 안 되는 크기로 대신해 줍니다. STEP 4 에서 우리가 손으로 박은 인라인 스크립트도 next-themes 가 알아서 넣어 줍니다.
① Provider 컴포넌트 — 라이브 코딩
next-themes 의 ThemeProvider 는 클라이언트 컴포넌트라서, 그대로 서버 컴포넌트인 layout 에 넣기 까다롭습니다. 얇은 래퍼를 하나 만듭니다.
먼저 껍데기와 할 일:
"use client"; import { ThemeProvider } from "next-themes"; export default function Providers({ children }: { children: React.ReactNode }) { // ThemeProvider 로 children 을 감싸 돌려준다 // 옵션: data-theme 속성 사용 / 기본은 시스템 설정 / 시스템 연동 켜기 return null; }
할 일을 코드로:
📄 app/providers.tsx:
"use client"; import { ThemeProvider } from "next-themes"; export default function Providers({ children, }: { children: React.ReactNode; }) { return ( <ThemeProvider attribute="data-theme" defaultTheme="system" enableSystem disableTransitionOnChange > {children} </ThemeProvider> ); }
🔹 attribute="data-theme" — next-themes 가 <html> 에 data-theme="dark" 를 설정합니다. 우리가 STEP 3 에서 만든 :root[data-theme="dark"] CSS 와 그대로 맞물립니다. (Tailwind 를 쓴다면 attribute="class" 로 두고 dark: 를 씁니다.)
🔹 defaultTheme="system" + enableSystem — 저장된 선택이 없으면 OS 설정을 따릅니다.
🔹 disableTransitionOnChange — 테마를 바꾸는 순간의 CSS transition 을 잠깐 꺼서 더 깔끔하게 전환합니다.
② layout 수정 — 인라인 스크립트 제거, Providers 로 감싸기
STEP 4 에서 손으로 박은 themeScript 와 <script> 는 지웁니다. next-themes 가 대신 넣어 줘요. suppressHydrationWarning 은 유지 — next-themes 도 <html> 을 바꾸므로 똑같이 필요합니다.
📄 app/layout.tsx:
import type { Metadata } from "next"; import "./globals.css"; import Providers from "./providers"; export const metadata: Metadata = { title: "다크 모드 튜토리얼", description: "Next.js 다크 모드를 단계별로 익히기", }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="ko" suppressHydrationWarning> <body> <Providers>{children}</Providers> </body> </html> ); }
③ 토글 컴포넌트 — 라이브 코딩 (mounted 패턴)
next-themes 는 useTheme() 훅을 줍니다. 그런데 서버에서는 사용자 테마를 모르니 useTheme() 값이 undefined 입니다. 그 값을 그대로 버튼에 글자로 렌더링하면 버튼에서 hydration mismatch 가 납니다.
그래서 mounted 패턴을 씁니다: "마운트되기 전엔 테마 글자를 안 그린다."
먼저 껍데기와 할 일:
"use client"; import { useEffect, useState } from "react"; import { useTheme } from "next-themes"; export default function ThemeToggle() { // 1. mounted 상태를 관리한다 (처음엔 false) // 2. useTheme() 에서 현재 테마와 setter 를 꺼낸다 // 3. 마운트되면 mounted 를 true 로 // 4. 마운트 전에는 글자 없는 placeholder 를 보여준다 // 5. 마운트 후에는 실제 테마를 보여주고, 클릭하면 반대로 전환 return null; }
1~3번을 코드로:
export default function ThemeToggle() { const [mounted, setMounted] = useState(false); const { resolvedTheme, setTheme } = useTheme(); useEffect(() => { setMounted(true); }, []); // 4, 5번 아직... }
resolvedTheme—theme이"system"일 수 있는데,resolvedTheme은 실제로 적용된"light"/"dark"입니다. 토글엔 이게 편해요.
4번 — placeholder:
if (!mounted) { return ( <button style={buttonStyle} aria-hidden> 테마 불러오는 중… </button> ); }
5번 — 실제 토글:
const next = resolvedTheme === "dark" ? "light" : "dark"; return ( <button style={buttonStyle} onClick={() => setTheme(next)}> 현재 테마: {resolvedTheme} — 클릭해서 {next} 로 전환 </button> );
완성된 파일 📄 app/ThemeToggle.tsx:
"use client"; import { useEffect, useState } from "react"; import { useTheme } from "next-themes"; const buttonStyle: React.CSSProperties = { padding: "8px 16px", background: "var(--card)", color: "var(--fg)", border: "1px solid var(--border)", borderRadius: 8, cursor: "pointer", minWidth: 260, }; export default function ThemeToggle() { const [mounted, setMounted] = useState(false); const { resolvedTheme, setTheme } = useTheme(); useEffect(() => { setMounted(true); }, []); if (!mounted) { return ( <button style={buttonStyle} aria-hidden> 테마 불러오는 중… </button> ); } const next = resolvedTheme === "dark" ? "light" : "dark"; return ( <button style={buttonStyle} onClick={() => setTheme(next)}> 현재 테마: {resolvedTheme} — 클릭해서 {next} 로 전환 </button> ); }
④ page 에서 토글 교체
📄 app/page.tsx — ThemeToggleNaive 대신 ThemeToggle 을 import 해서 씁니다. 그리고 STEP 3 에서 만든 app/ThemeToggleNaive.tsx 는 이제 안 쓰니 삭제합니다.
rm app/ThemeToggleNaive.tsx
import ThemeToggle from "./ThemeToggle"; export default function HomePage() { return ( <main style={{ maxWidth: 640, margin: "0 auto", padding: "48px 24px" }}> <h1 style={{ fontSize: 32, fontWeight: 800 }}>🌗 다크 모드 튜토리얼</h1> <p style={{ opacity: 0.7 }}>next-themes 완성 단계 (STEP 5).</p> <div style={{ marginTop: 16 }}> <ThemeToggle /> </div> <section style={{ marginTop: 24, padding: 20, background: "var(--card)", border: "1px solid var(--border)", borderRadius: 12, }} > <h2 style={{ marginTop: 0 }}>카드 예시</h2> <p>버튼을 눌러 테마를 바꿔 보세요. 새로고침해도 유지되고, 깜빡임도 없습니다.</p> <button style={{ padding: "8px 16px", background: "var(--accent)", color: "#fff", border: "none", borderRadius: 8, cursor: "pointer", }} > 강조 버튼 </button> </section> </main> ); }
✅ 확인하기
http://localhost:3500 접속.
- 토글 버튼으로 light ↔ dark 전환. ✅
- 새로고침 → 선택 유지 + 깜빡임 없음. ✅
- 개발자도구 Console → 경고 0, 에러 0. ✅
- localStorage 를 비우고(또는 시크릿 창) OS 다크 모드를 켜 보면 → 저장된 선택이 없으니 OS 를 따라 dark. ✅ (
enableSystem+defaultTheme="system") - 탭을 두 개 열고 한쪽에서 테마를 바꾸면 → 다른 탭도 따라 바뀝니다. ✅ (next-themes 의 멀티탭 동기화)
E2E 로 확인된 사실 (Playwright,
prefers-color-scheme에뮬레이션):
- 저장값 없음 + OS=light → 배경
rgb(255,255,255)/ OS=dark →rgb(24,24,27)(시스템 추종 OK)- 토글 클릭 →
data-theme="dark",localStorage.theme="dark"- 새로고침 시
domcontentloaded시점에 이미data-theme="dark"(깜빡임 없음)- hydration 경고 0건, 콘솔 에러 0건
정리
| 단계 | 한 일 | 깜빡임 | hydration 경고 | 토글 |
|---|---|---|---|---|
| STEP 2 | CSS 변수 + prefers-color-scheme | 없음 | 없음 | ❌ (OS 만 추종) |
| STEP 3 | naive useState/useEffect 토글 | 있음 | 없음 | ✅ |
| STEP 4 | 인라인 스크립트 + suppressHydrationWarning | 없음 | (의도된 것만 끔) | ✅ |
| STEP 5 | next-themes | 없음 | 없음 | ✅ + 시스템/멀티탭 |
교육생 질문 최종 답
Q1. 어떤 방식이 제일 좋나요?
- "CSS 로 두 테마 정의" 와 "next-themes" 는 경쟁이 아니라 같이 쓰는 짝입니다. CSS 는 색을 정의(STEP 2 의 토대), next-themes 는 어떤 테마를 쓸지 관리.
- 토글이 필요 없으면 → 순수 CSS
prefers-color-scheme만으로 충분 (STEP 2). - 토글이 필요하면 → next-themes (STEP 5). 직접 만들면 STEP 3→4 처럼 깜빡임·경고·엣지케이스를 하나하나 잡아야 하는데, 라이브러리가 다 해결해 줍니다. 그래서 표준입니다.
Q2. hydration 경고를 suppressHydrationWarning 으로 끄는 게 괜찮나요?
- 일반적으로는 끄면 안 됩니다. hydration mismatch 는 보통 진짜 버그입니다.
- 단,
<html>의 테마 속성 불일치 라는 한 가지는 예외입니다. 깜빡임을 없애려면 페인트 전 스크립트가<html>을 바꿔야 하고, 그러면 이 mismatch 는 피할 수 없고 의도된 것입니다. suppressHydrationWarning은 그 요소 한 개, 한 단계 깊이 만 끕니다. 페이지 안 컴포넌트의 진짜 버그는 그대로 잡힙니다. 그래서<html>에 붙이는 건 공식 권장 사용법이고 "버그를 무시하는 것" 이 아닙니다.

댓글
댓글을 작성하려면 이 필요합니다.