๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๋ฐ”๋กœ๊ฐ€๊ธฐ
๋…ธํŠธ ๋ชฉ๋ก

๐ŸŒ— Next.js ๋‹คํฌ ๋ชจ๋“œ ์™„์ „ ์ •๋ณต โ€” ๊นœ๋นก์ž„๋„ ์—†๊ณ  ๊ฒฝ๊ณ ๋„ ์—†๊ฒŒ

๊น€์„ฑ๋ฐ•760
๋ชฉ์ฐจ9

๋“ค์–ด๊ฐ€๋ฉฐ โ€” ๊ต์œก์ƒ์ด ํ•œ ๋‘ ๊ฐ€์ง€ ์งˆ๋ฌธ

Q1. "CSS์—์„œ dark/light ๊ฐ’์„ ๋‘˜ ๋‹ค ์ •์˜ํ•ด ๋‘๊ณ  CSS๋กœ ํ•„ํ„ฐ๋งํ•˜๋Š” ๋ฐฉ์‹(๋ฐ”๋‹๋ผ์ฒ˜๋Ÿผ)"๊ณผ
"next-themes ๋ฐฉ์‹" ์ค‘ ๋ญ๊ฐ€ ์ œ์ผ ์ข‹๋‚˜์š”?

Q2. hydration ์ด์Šˆ๋Š” suppressHydrationWarning ๋กœ ํ•ด๊ฒฐํ•ด ๋ฒ„๋ฆฌ๊ณ  ์“ฐ๋˜๋ฐ, ๊ทธ๋ƒฅ ๋ฌด์‹œํ•ด๋„ ๊ดœ์ฐฎ๋‚˜์š”?

๋จผ์ € ๊ฒฐ๋ก ๋ถ€ํ„ฐ ์ •๋ฆฌํ•˜๊ณ , ๊ทธ๋‹ค์Œ ์ง์ ‘ ํ”„๋กœ์ ํŠธ๋ฅผ ๋งŒ๋“ค๋ฉฐ ํ•œ ๋‹จ๊ณ„์”ฉ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.


Part 0. ๋‘ ์งˆ๋ฌธ์— ๋Œ€ํ•œ ๋‹ต

A1. ๋‘ ๋ฐฉ์‹์€ ๊ฒฝ์Ÿ ๊ด€๊ณ„๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค โ€” ์—ญํ• ์ด ๋‹ค๋ฆ…๋‹ˆ๋‹ค

๋‹คํฌ ๋ชจ๋“œ๋Š” ์‚ฌ์‹ค ๋‘ ๊ฐœ์˜ ๋ฌธ์ œ๋กœ ๋‚˜๋‰ฉ๋‹ˆ๋‹ค.

๋ฌธ์ œ๋‹ด๋‹น๋„๊ตฌ
โ‘  ์ƒ‰์„ ์–ด๋–ป๊ฒŒ ์ •์˜ํ• ๊นŒCSSCSS ๋ณ€์ˆ˜, 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 ์ ‘์†.

  1. ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด ํ…Œ๋งˆ๊ฐ€ ๋ฐ”๋€๋‹ˆ๋‹ค. โœ…
  2. ๊ฐœ๋ฐœ์ž๋„๊ตฌ โ†’ Application โ†’ Local Storage ์— theme ํ‚ค๊ฐ€ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค. โœ…
  3. ๋‹คํฌ๋กœ ๋ฐ”๊พผ ๋’ค ์ƒˆ๋กœ๊ณ ์นจ(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 ์ ‘์†.

  1. ํ† ๊ธ€ ๋ฒ„ํŠผ์œผ๋กœ light โ†” dark ์ „ํ™˜. โœ…
  2. ์ƒˆ๋กœ๊ณ ์นจ โ†’ ์„ ํƒ ์œ ์ง€ + ๊นœ๋นก์ž„ ์—†์Œ. โœ…
  3. ๊ฐœ๋ฐœ์ž๋„๊ตฌ Console โ†’ ๊ฒฝ๊ณ  0, ์—๋Ÿฌ 0. โœ…
  4. localStorage ๋ฅผ ๋น„์šฐ๊ณ (๋˜๋Š” ์‹œํฌ๋ฆฟ ์ฐฝ) OS ๋‹คํฌ ๋ชจ๋“œ๋ฅผ ์ผœ ๋ณด๋ฉด โ†’ ์ €์žฅ๋œ ์„ ํƒ์ด ์—†์œผ๋‹ˆ OS ๋ฅผ ๋”ฐ๋ผ dark. โœ… (enableSystem + defaultTheme="system")
  5. ํƒญ์„ ๋‘ ๊ฐœ ์—ด๊ณ  ํ•œ์ชฝ์—์„œ ํ…Œ๋งˆ๋ฅผ ๋ฐ”๊พธ๋ฉด โ†’ ๋‹ค๋ฅธ ํƒญ๋„ ๋”ฐ๋ผ ๋ฐ”๋€๋‹ˆ๋‹ค. โœ… (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 2CSS ๋ณ€์ˆ˜ + prefers-color-scheme์—†์Œ์—†์ŒโŒ (OS ๋งŒ ์ถ”์ข…)
STEP 3naive useState/useEffect ํ† ๊ธ€์žˆ์Œ์—†์Œโœ…
STEP 4์ธ๋ผ์ธ ์Šคํฌ๋ฆฝํŠธ + suppressHydrationWarning์—†์Œ(์˜๋„๋œ ๊ฒƒ๋งŒ ๋”)โœ…
STEP 5next-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> ์— ๋ถ™์ด๋Š” ๊ฑด ๊ณต์‹ ๊ถŒ์žฅ ์‚ฌ์šฉ๋ฒ•์ด๊ณ  "๋ฒ„๊ทธ๋ฅผ ๋ฌด์‹œํ•˜๋Š” ๊ฒƒ" ์ด ์•„๋‹™๋‹ˆ๋‹ค.

๋Œ“๊ธ€

๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

๋Œ“๊ธ€์„ ์ž‘์„ฑํ•˜๋ ค๋ฉด ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.