다크모드는 이제 기본 기대치에 가깝습니다. 문제는 Next.js의 서버 컴포넌트 환경에서 테마를 전환하는 일이 생각보다 까다롭다는 데 있습니다. 서버는 사용자가 어떤 테마를 골랐는지 알 길이 없으니까요.
DaisyUI의 테마 시스템과 next-themes를 조합해 깜빡임 없는 다크모드를 구현한 과정을 적어둡니다. 색을 반전시키는 것보다 실제로 부딪힌 함정을 메우는 데 시간이 더 걸렸습니다.
1. 기술 스택 선택
| 라이브러리 | 역할 | 선택 이유 |
|---|
| DaisyUI | UI 컴포넌트 + 테마 | data-theme 기반 전환으로 CSS 변수만 바뀜 |
| next-themes | 테마 상태 관리 | SSR 호환, localStorage 동기화, 시스템 테마 감지 |
| Tailwind CSS | 스타일링 | DaisyUI와 자연스러운 통합 |
역할 분담이 깔끔합니다. DaisyUI는 보이는 색을, next-themes는 어떤 테마인지 기억하고 적용하는 일을 맡습니다. 이 둘을 분리해서 생각하면 이후 구현이 한결 단순해집니다.
2. DaisyUI 테마 설정
DaisyUI는 data-theme 속성 하나로 테마를 전환합니다. 먼저 CSS에서 사용할 테마를 선언합니다.
css
/* globals.css */
@plugin "daisyui" {
themes: nord --default, dim --prefersdark;
}
nord가 라이트 테마 기본값이고, dim은 시스템 다크모드일 때 자동 적용됩니다.
이렇게 선언해두면 이후 할 일은 <html>의 data-theme 값을 nord와 dim 사이에서 바꿔주는 것뿐입니다. 색상 자체는 DaisyUI가 CSS 변수로 알아서 갈아끼웁니다.
3. ThemeProvider 설정
next-themes의 ThemeProvider는 클라이언트 컴포넌트로 감싸야 합니다. 여기서 가장 중요한 한 줄은 attribute="data-theme"입니다. DaisyUI가 이 속성을 보고 테마를 판단하기 때문에, 둘을 맞춰주는 게 연결의 핵심입니다.
tsx
// components/ThemeProvider/index.tsx
'use client';
import { ThemeProvider as Provider } from 'next-themes';
const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
return (
<Provider attribute="data-theme" defaultTheme="nord">
{children}
</Provider>
);
};
그리고 이 Provider로 루트 레이아웃을 감쌉니다.
tsx
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="ko" suppressHydrationWarning>
<body>
<ThemeProvider>
<Header />
<main>{children}</main>
<Footer />
</ThemeProvider>
</body>
</html>
);
}
suppressHydrationWarning은 서버와 클라이언트의 data-theme 값이 달라서 생기는 Hydration 경고를 잠재워 줍니다. 서버는 테마를 모른 채 렌더링하고 클라이언트가 마운트되며 실제 값을 채우기 때문에, 이 차이는 의도된 것입니다.
4. 다크모드 토글 버튼 만들기
단순한 아이콘 토글 대신 해와 달이 전환되는 애니메이션 토글을 만들었습니다.
tsx
'use client';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
function DarkMode() {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
const isDark = theme === 'dim';
return (
<div
className={`w-16 h-8 rounded-3xl cursor-pointer relative
transition-all duration-500 overflow-hidden
${isDark ? 'bg-[#6148de]' : 'bg-[#c9f8ff]'}`}
onClick={() => setTheme(isDark ? 'nord' : 'dim')}
>
{/* 구름 (라이트모드) */}
<img
src="/images/clouds.svg"
className={`absolute top-[17px] z-[2] transition-transform
${isDark ? '-translate-x-8' : 'translate-x-[18px]'}`}
/>
{/* 별 (다크모드) */}
<img
src="/images/stars.svg"
className={`absolute top-1 left-2 z-[3] transition-transform
${isDark ? 'translate-y-1' : 'translate-y-10'}`}
/>
{/* 해/달 원형 */}
<div className={`absolute w-6 h-6 top-1 rounded-full transition-all
${isDark ? 'bg-white translate-x-8' : 'bg-[#ffda16] shadow-[0_0_5px_#ffda16] translate-x-1'}`}
/>
</div>
);
}
Hydration 문제 해결
mounted 상태를 쓰는 이유가 이 컴포넌트의 전부라고 해도 과언이 아닙니다. 서버에서는 사용자의 테마를 알 수 없으니, 클라이언트에서 마운트가 끝난 뒤에만 테마에 의존하는 UI를 그립니다.
tsx
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
if (!mounted) return null;
이 패턴을 빠뜨리면 서버와 클라이언트의 HTML이 어긋나면서 Hydration Error가 터집니다. 테마값에 의존하는 UI는 마운트 이후에만 그린다는 원칙으로 기억해두면 좋습니다.
5. 코드 블록 다크모드 대응
rehype-pretty-code로 생성된 코드 블록도 테마를 따라가야 합니다. 여기서도 CSS 변수가 해결사입니다.
css
/* 라이트 테마 (기본) */
code span {
color: var(--shiki-light);
background-color: var(--shiki-light-bg);
}
/* 다크 테마 */
[data-theme="dim"] code span {
color: var(--shiki-dark);
background-color: var(--shiki-dark-bg);
}
rehype-pretty-code에 theme 옵션으로 라이트와 다크 두 테마를 함께 지정하면, 각 코드 토큰에 두 색상이 모두 CSS 변수로 박혀 들어옵니다. 그래서 토글 한 번에 코드 색 전체가 자연스럽게 갈아끼워집니다.
6. Glass Morphism과 다크모드
블로그 전체에 Glass Morphism 스타일을 입혔는데, 라이트와 다크 양쪽에서 모두 잘 보이게 하려면 조건부 스타일링이 필요했습니다.
tsx
const glassCard = `
rounded-3xl
border border-white/40 bg-base-100/70
shadow-[0_20px_60px_-40px_rgba(15,23,42,0.6)]
backdrop-blur-xl
dark:border-white/10 dark:bg-base-100/15
`;
Tailwind의 dark: 접두사로 다크모드일 때 투명도와 테두리 밝기를 낮춥니다. 배경색의 기준이 되는 DaisyUI의 base-100은 테마에 따라 자동으로 바뀌므로, 유리판 위로 비치는 배경도 자연스럽게 톤이 맞춰집니다.
7. 로고 이미지 테마 대응
색이 칠해진 요소만 신경 쓰면 끝일 것 같지만 이미지도 빠뜨릴 수 없습니다. 어두운 배경에는 밝은 로고가, 밝은 배경에는 어두운 로고가 보이도록 테마에 따라 이미지 자체를 교체합니다.
tsx
const { theme } = useTheme();
<Image
src={theme === 'dim' ? '/images/logo_dark.png' : '/images/logo.png'}
alt="logo"
width={60}
height={30}
/>
8. 흔히 겪는 문제와 해결
화면 깜빡임 (FOUC)
next-themes는 <html> 태그에 인라인 스크립트를 주입해, 페이지가 그려지기 직전에 초기 테마를 빠르게 적용합니다. suppressHydrationWarning과 함께 쓰면 라이트에서 다크로 번쩍이는 깜빡임이 거의 사라집니다.
CSS 변수 우선순위
DaisyUI 테마 변수가 Tailwind 유틸리티보다 먼저 로드돼야 합니다. globals.css에서 @plugin "daisyui" 선언을 Tailwind import 뒤에 두세요. 순서가 어긋나면 변수가 덮여 색이 엉킵니다.
시스템 테마 변경 감지
next-themes가 prefers-color-scheme 미디어 쿼리를 자동으로 감지합니다. 사용자가 OS 설정에서 다크모드를 켜면 별도 처리 없이 반영됩니다.
마무리
다크모드 구현은 색을 반전시키는 일이 아니었습니다. Hydration, 코드 블록 테마, 이미지 전환, FOUC까지 챙겨야 할 구석이 의외로 많았습니다. DaisyUI와 next-themes 조합은 이 복잡함의 상당 부분을 대신 떠안아 준 선택이었습니다.
정리하면, CSS 변수 기반 테마 시스템을 일관되게 쓰는 것이 이 구현의 뼈대입니다. 색을 하드코딩하는 곳이 하나라도 생기면 그 지점만 테마를 따라오지 않아 결국 다시 손대게 되더군요.