학교에서 진행하는 Flooding 프로젝트에서 다크모드 구현을 담당하여 진행 중 여러 문제가 발생하여 해결하는 과정을 작성해 보았습니다.
"use client";
import Moon from "@/shared/asset/svg/Moon";
import Sun from "@/shared/asset/svg/Sun";
import { useState } from "react";
export function DarkModeToggle() {
const [isDark, setIsDark] = useState(false);
const toggle = () => {
setIsDark((prev) => !prev);
document.documentElement.classList.toggle("dark");
};
return (
<button
onClick={toggle}
className={`relative w-[70px] h-[39px] rounded-[43px] transition-colors duration-300 cursor-pointer flex items-center p-1 ${
isDark ? "bg-(--background-surface)" : "bg-(--color-sub-3)"
}`}
>
<span
className={`w-[31px] h-[31px] rounded-full flex items-center justify-center transition-transform duration-300 ${
isDark ? "bg-(--color-sub-1) translate-x-[30px]" : "translate-x-0"
}`}
>
{isDark ? <Moon /> : <Sun />}
</span>
</button>
);
}
처음 다크모드 구현시에는 이렇게 useState로만 상태를 관리하여
다크 모드를 설정했더라도 페이지 이동이나 새로고침 시 라이트 모드로 돌아가는 문제가 발생했습니다.
그리하여 리팩토링을 통해 localStorage를 사용하여 사용자의 테마 설정을 저장하고, 컴포넌트가 마운트될 때 useEffect를 사용해 저장된 값을 불러오도록 수정하였습니다.
코드↓
import Moon from "@/shared/asset/svg/Moon";
import Sun from "@/shared/asset/svg/Sun";
import { useEffect, useState } from "react";
export function DarkModeToggle() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const stored = localStorage.getItem("theme");
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
const shouldBeDark = stored ? stored === "dark" : prefersDark;
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsDark(shouldBeDark);
document.documentElement.classList.toggle("dark", shouldBeDark);
}, []);
const toggle = () => {
const next = !isDark;
setIsDark(next);
document.documentElement.classList.toggle("dark", next);
localStorage.setItem("theme", next ? "dark" : "light");
};
return (
<button
onClick={toggle}
className={`relative w-[70px] h-[39px] rounded-[43px] transition-colors duration-300 cursor-pointer flex items-center p-1 ${
isDark ? "bg-background-surface" : "bg-sub-3"
}`}
>
<span
className={`w-[31px] h-[31px] rounded-full flex items-center justify-center transition-transform duration-300 ${
isDark ? "bg-sub-1 translate-x-[30px]" : "translate-x-0"
}`}
>
{isDark ? <Moon /> : <Sun />}
</span>
</button>
);
}
useEffect는 클라이언트 hydration 이후에야 실행되므로,
그 전까지는 localStorage에 접근할 수 없어 테마가 적용되지 않은 상태로 먼저 렌더링됩니다.
이후 useEffect가 실행되며 테마가 전환되면서 화면이 깜빡이게 됩니다.

테마 초기화 로직이 DarkModeToggle 컴포넌트 내부에 종속되어 이 컴포넌트를 사용하지 않는 페이지에서 테마를 불러올 수 없는 문제가 발생합니다.
로그인 페이지에는 헤더가 없으므로 테마를 적용하는 useEffect가 실행되지 않습니다. 결과적으로 localStorage에 저장된 사용자 설정을 읽지 못하고, layout.tsx에 하드코딩된 className="dark만 적용되게 됩니다.
FOUC 문제는 useEffect가 클라이언트에서 뒤늦게 실행되면서 localStorage를 읽고 classList.toggle을 호출할 때 이미 렌더링된 화면이 순간적으로 바뀌어 발생하였습니다.
이를 해결하기 위해 layout.tsx에 인라인 스크립트를 추가하였는데
인라인 스크립트는 HTML 파싱 시점에 즉시 실행되어 React hydration보다 먼저 dark 클래스를 적용하므로 깜빡임이 발생하지 않습니다.
따라서 useEffect에서는 localStorage를 다시 읽는 중복 로직을 제거하고
인라인 스크립트가 적용한 DOM 상태를 classList.contains("dark")로 읽어와 React state와 동기화하도록 수정하였습니다.
export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="ko" suppressHydrationWarning //className="dark">
<body className="antialiased">
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', stored ? stored === 'dark' : prefersDark);
})();
`,
}}
/>
<Providers>{children}</Providers>
</body>
</html>
);
}
import Moon from "@/shared/asset/svg/Moon";
import Sun from "@/shared/asset/svg/Sun";
import { useEffect, useState } from "react";
export function DarkModeToggle() {
const [isDark, setIsDark] = useState<boolean | null>(null);
useEffect(() => {
const stored = localStorage.getItem("theme");
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
const shouldBeDark = stored ? stored === "dark" : prefersDark;
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsDark(document.documentElement.classList.contains("dark"));
}, []);
const toggle = () => {
if (isDark === null) return;
const next = !isDark;
setIsDark(next);
document.documentElement.classList.toggle("dark", next);
localStorage.setItem("theme", next ? "dark" : "light");
};
if (isDark === null) {
return <div className="w-[70px] h-[39px]" />;
}
return (
<button
onClick={toggle}
className={`relative w-[70px] h-[39px] rounded-[43px] transition-colors duration-300 cursor-pointer flex items-center p-1 ${
isDark ? "bg-background-surface" : "bg-sub-3"
}`}
>
<span
className={`w-[31px] h-[31px] rounded-full flex items-center justify-center transition-transform duration-300 ${
isDark ? "bg-sub-1 translate-x-[30px]" : "translate-x-0"
}`}
>
{isDark ? <Moon /> : <Sun />}
</span>
</button>
);
}
이렇게 첫 Darkmode를 구현해 보았습니다.
단순히 상태 관리만으로 구현했다가 FOUC, 테마 종속 문제 등 여러 문제를 마주하게 되었고 이를 해결하는 과정에서 SSR 환경에서의 렌더링 흐름과 hydration에 대해 더 깊이 이해할 수 있었습니다.
앞으로도 이런 경험들을 쌓아가며 성장해 나가겠습니다!
conTextAPI에 대해 알아보면 좋을 것 같아요