๐ Next.js ๋คํฌ ๋ชจ๋ ์์ ์ ๋ณต โ ๊น๋นก์๋ ์๊ณ ๊ฒฝ๊ณ ๋ ์๊ฒ
๋ชฉ์ฐจ9
๋ค์ด๊ฐ๋ฉฐ โ ๊ต์ก์์ด ํ ๋ ๊ฐ์ง ์ง๋ฌธ
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>์ ๋ถ์ด๋ ๊ฑด ๊ณต์ ๊ถ์ฅ ์ฌ์ฉ๋ฒ์ด๊ณ "๋ฒ๊ทธ๋ฅผ ๋ฌด์ํ๋ ๊ฒ" ์ด ์๋๋๋ค.
๋๊ธ
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ์ด ํ์ํฉ๋๋ค.