Next.js 블로그에 다크모드 적용해보기
다크모드란?
웹사이트나 앱에서 보통 기본으로 보이는 밝은 배경에 검은 글씨 화면을 라이트모드라고 하고, 반대로 어두운 배경에 밝은 글씨로 보여주는 걸 다크모드라고 한다. 요즘은 거의 모든 앱이나 웹사이트에서 둘 다 지원하고 있고, macOS, iOS, Windows, Android 같은 OS에서도 시스템 설정으로 다크모드를 켤 수 있다.

밤에 화면 볼 때 눈이 덜 아프고, OLED 디스플레이에서는 검은 픽셀이 아예 꺼지기 때문에 배터리도 아낄 수 있다고 한다.
특히 개발자들이 다크모드를 좋아하는 것 같다. 밝은 화면에서는 버그가 꼬일 수 있기 때문이다!
구현 전략
이번에 적용시키려고 하는 것은 단순히 라이트/다크 전환 토글만보다는 사용자가 직접 라이트, 다크를 선택할 수도 있고 초기 값으로 시스템 설정을 따라가는 옵션으로 선택하려고 한다.
예를 들어 OS에서 다크모드를 켜두면 블로그도 자동으로 다크모드가 되는 식이다.
요즘 대부분의 사이트가 이 방식을 쓰고 있는 것 같다.
next-themes
다크모드를 직접 구현하는 것도 가능하다.
localStorage에 테마를 저장해두고, 페이지 로드할 때 읽어서 클래스를 붙이면 된다.
하지만 직접 해보면 생각보다 번거롭고, 예외 케이스가 많다.
다크모드를 구현할 때 가장 먼저 마주치는 문제는 FOUC(Flash of Unstyled Content) 인데
쉽게 말하면 화면이 번쩍 깜빡이는 현상이다.
Next.js는 서버에서 HTML을 먼저 만들어서 보내는데
이때는 localStorage를 읽을 수가 없어서 항상 라이트모드로 렌더링된다.
그러다 클라이언트에서 자바스크립트가 실행되면 그제서야 다크모드로 바뀌면서
흰 화면이 번쩍 보였다가 어두워진다. 이건 사용자가 불편을 느낄 수 있는 부분이다.
그래서 next-themes를 쓰게 되었다.
next-themes는 이걸 <script>를 <head>에 주입해서 해결한다.
HTML이 파싱되기 전에 테마 클래스를 먼저 붙여버려서 깜빡임이 없다.
주요 특징을 정리하면,
- FOUC 방지 (페이지 로드 시 깜빡임 없음)
- 시스템 테마 감지 (
prefers-color-scheme) 자동 처리 localStorage저장/복원 내장useTheme훅으로 현재 테마 조회 및 변경 가능- Next.js App Router 지원
- 용량이 가벼움 (~2KB)
next-themes 설치 & ThemeProvider 설정
pnpm add next-themes먼저 라이브러리를 설치하고
ThemeProvider 컴포넌트를 만들어준다.
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
};옵션이 세 개 있는데,
attribute="class"—<html>에class="dark"를 붙여주는 방식이다. Tailwind CSS가 이 클래스를 보고 다크모드 스타일을 적용한다defaultTheme="system"— 처음 방문하면 OS 설정을 따라간다.disableTransitionOnChange— 테마 바꿀 때 CSS transition을 잠깐 꺼서 색이 즉시 바뀌게 한다. 안 하면 전환 중간에 이상한 색이 섞여 보인다
이제 Root Layout에서 이 Provider로 감싸주면 된다.
import { ThemeProvider } from '@/components/ThemeProvider';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko" suppressHydrationWarning>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}suppressHydrationWarning이 이름이 엄청 길어서 좀 무서워 보이지만,
이걸 이해하려면 먼저 hydration이 뭔지 알아야 한다.
Next.js 같은 SSR 프레임워크는 서버에서 HTML을 먼저 만들어서 브라우저에 보낸다. 브라우저는 이 HTML을 화면에 그리고, 그다음 자바스크립트가 로드되면 React가 서버에서 만든 HTML에 이벤트 핸들러나 상태 같은 인터랙티브한 기능을 붙여준다. 이 과정을 hydration(수화)이라고 한다. 쉽게 말하면 서버가 만든 정적인 HTML에 React가 생명을 불어넣는 과정이다.
그런데 이때 서버에서 만든 HTML이랑 클라이언트에서 React가 그리는 HTML이 다르면 React가 "어? 서버랑 클라이언트가 다른데?" 하면서 경고를 띄운다. 이게 hydration mismatch다.
next-themes는 <html>에 class="dark" 같은 클래스를 주입하는데,
서버에서는 아직 사용자 테마를 모르니까 클래스가 없고, 클라이언트에서는 붙어 있다.
그래서 서버/클라이언트 HTML이 달라지면서 hydration mismatch 경고가 뜬다.
하지만 이건 next-themes가 의도적으로 하는 동작이라 실제로 문제가 되는 건 아니기 때문에 경고를 꺼주는 역할인 suppressHydrationWarning을 붙혀준다!
Tailwind CSS에서 다크모드 설정
Tailwind CSS 3까지는 다크모드를 쓰려면 tailwind.config.ts에서 별도로 설정해줘야 했다.
// Tailwind CSS 3 — tailwind.config.ts
const config = {
darkMode: ['class'],
theme: {
extend: {
colors: {
background: 'var(--background)',
'text-primary': 'var(--text-primary)',
// ...
},
},
},
};darkMode: ['class']를 넣어줘야 dark: prefix가 동작했고,
CSS 변수를 Tailwind 클래스로 쓰려면 theme.extend.colors에 하나하나 등록해야 했다.
Tailwind CSS 4에서는 이게 많이 간단해졌다.
tailwind.config.ts 파일이 아예 없어지고, CSS 파일에서 @theme inline으로 바로 등록하면 된다.
/* Tailwind CSS 4 — globals.css */
@theme inline {
--color-background: var(--background);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-accent: var(--accent);
--color-border: var(--border);
}darkMode 설정도 필요 없다. Tailwind CSS 4는 .dark 클래스를 기본으로 인식한다.
설정 파일이랑 CSS를 왔다 갔다 할 필요 없이 CSS 한 곳에서 다 관리할 수 있어서 편하다.
이렇게 해두면 bg-background, text-text-primary, border-border 같은 Tailwind 클래스를 그대로 쓸 수 있고,
테마가 바뀌면 CSS 변수 값이 바뀌니까 클래스는 건드릴 필요가 없다.
컴포넌트 코드를 하나도 안 고쳐도 다크모드가 적용된다.
다크모드 CSS 변수 추가
이제 .dark 셀렉터에 다크모드용 색상 값을 넣어주자.
Tailwind CSS 4에서는 기본 색상 팔레트를 OKLCH 기반으로 변경했다.
OKLCH는 CSS Color Module에서 제공하는 색상 함수로,
oklch(L C H) 형태로 밝기(Lightness), 채도(Chroma), 색상(Hue)을 표현한다.
기존에 많이 사용하던 hex나 rgb와 달리 OKLCH는 사람의 시각에 더 가깝게 설계된 색상 공간이기 때문에 밝기 조절이 훨씬 직관적이다.
예를 들어 첫 번째 값인 Lightness만 조절하면 같은 색상 톤을 유지한 채 밝기만 자연스럽게 변경할 수 있다.
그래서 최근 디자인 시스템이나 UI 라이브러리에서도 OKLCH 기반 색상 팔레트를 사용하는 경우가 점점 늘어나고 있다.
이 특성 덕분에 다크모드용 색상 팔레트를 만들 때 특히 유용하다.
:root {
--background: oklch(0.97 0.008 90);
--text-primary: oklch(0.15 0 0);
--text-secondary: oklch(0.45 0 0);
--accent: oklch(0 0 0);
--border: oklch(0.92 0.005 90);
}
.dark {
--background: oklch(0.17 0.004 286);
--text-primary: oklch(0.93 0.003 264);
--text-secondary: oklch(0.55 0.005 264);
--accent: oklch(0.93 0.003 264);
--border: oklch(0.24 0.006 286);
}테마 토글 버튼 만들기
사용자가 직접 테마를 바꿀 수 있는 토글 버튼도 만들어야 한다. light, dark, system 세 가지 옵션을 드롭다운으로 선택하는 방식으로 만들었다.
토글 버튼을 만들 때 한 가지 문제가 있는데
next-themes의 useTheme은 클라이언트에서만 현재 테마를 알 수 있다.
서버에서는 어떤 테마인지 모르기 때문에 서버에서 그린 HTML이랑 클라이언트에서 그린 HTML이 달라지고,
React가 이 차이를 감지하면 hydration mismatch 에러를 띄운다.
아까 suppressHydrationWarning 쓰면 되는 거 아닌가? 싶을 수 있는데,
suppressHydrationWarning은 속성(attribute) 차이에 대한 경고만 꺼주는 거다.
아까 <html> 태그에 쓴 건 class="dark"라는 속성이 서버/클라이언트에서 다른 거라 괜찮았지만,
토글 버튼은 서버에서 아예 null을 반환하고 클라이언트에서는 <button>을 그리는 식이라
DOM 구조 자체가 달라지는 거라 suppressHydrationWarning으로는 해결이 안 된다.
그래서 클라이언트에서 마운트된 후에만 토글 버튼을 보여주려고 useState + useEffect 조합으로 진행했다.
'use client';
import { useState, useEffect } from 'react';
import { useTheme } from 'next-themes';
export const ThemeToggle = () => {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
// ... 토글 렌더링
};서버에서는 mounted가 false라 null을 반환하고,
클라이언트에서 useEffect가 실행되면 true로 바뀌면서 토글 버튼이 나타난다.
동작은 잘 된다.
useEffect에서 useSyncExternalStore로
근데 최근 추가된 React Compiler의 린트 플러그인(eslint-plugin-react-compiler)을 사용하면 이 패턴에 경고가 뜬다.

useEffect 안에서 바로 setState를 호출하면
react-compiler가 이걸 불필요한 Effect로 판단하고 경고를 띄운다.
React에서 useEffect는 원래 외부 시스템과의 동기화(API 호출, DOM 조작, 이벤트 리스너 등)를 위한 훅이다.
그런데 여기서는 단순히 마운트됐는지 확인하는 용도로 쓰고 있으니까
Effect의 본래 목적이랑 안 맞다고 판단하는 것 이다.
실제로 React 공식 문서 You Might Not Need an Effect라는 페이지에서 Effect 없이 해결할 수 있는 케이스들을 정리해두고 있다.
그래서 useSyncExternalStore를 쓰는 방식으로 바꿨다.
useSyncExternalStore는 React 18에서 추가된 훅으로,
원래는 Redux 같은 외부 스토어의 상태를 React 렌더링 사이클과 안전하게 동기화하기 위해 만들어졌다.
이 훅은 인자를 세 개 받는다.
const value = useSyncExternalStore(
subscribe, // 외부 스토어 구독 함수
getSnapshot, // 클라이언트에서 값을 가져오는 함수
getServerSnapshot, // 서버에서 값을 가져오는 함수 (SSR용)
);핵심은 세 번째 인자 getServerSnapshot이다.
이 함수는 서버에서 렌더링할 때만 호출되고, 두 번째 인자 getSnapshot은 클라이언트에서만 호출된다.
이 차이를 이용하면 서버/클라이언트를 깔끔하게 구분할 수 있다.
const mounted = useSyncExternalStore(
() => () => {}, // subscribe — 구독할 외부 스토어가 없으니 빈 함수
() => true, // getSnapshot — 클라이언트에서는 true
() => false, // getServerSnapshot — 서버에서는 false
);서버에서는 false를 반환하니까 토글이 렌더링되지 않고,
클라이언트에서는 true를 반환하니까 토글이 나타난다.
useEffect 없이도 서버/클라이언트를 구분할 수 있고, 린트 경고도 안 뜬다.
'use client';
import { useState, useEffect, useRef, useSyncExternalStore } from 'react';
import { useTheme } from 'next-themes';
import { Sun, Moon, Monitor } from 'lucide-react';
export const ThemeToggle = () => {
const { theme, setTheme } = useTheme();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const mounted = useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
if (!mounted) return null;
const options = [
{ value: 'light', label: 'light', icon: Sun },
{ value: 'dark', label: 'dark', icon: Moon },
{ value: 'system', label: 'system', icon: Monitor },
];
// ... 드롭다운 렌더링
};이 방식 덕분에 hydration mismatch 없이 다크모드 토글을 안정적으로 구현할 수 있었다.
코드 블록 듀얼 테마
기술 블로그라 코드 블록이 꽤 많은데 다크모드로 바꾸니 코드가 너무 어둡게 보이는 문제가 있었다.
이 블로그는 코드 하이라이팅에 rehype-pretty-code를 쓰고 있는데,
내부적으로 Shiki라는 엔진을 사용한다.
Shiki에서 듀얼 테마를 지원해서 한 번에 두 가지 테마를 적용할 수 있다.
// velite.config.ts
rehypePrettyCode,
{
theme: {
light: 'min-light',
dark: 'github-dark',
},
keepBackground: false,
},keepBackground: false는 Shiki가 자체 배경색을 넣지 않게 하는 설정이다.
배경은 CSS 변수로 사이트 전체 톤에 맞추고 있으니까 Shiki 기본값은 안 쓰는 게 낫다.
이렇게 설정하면 Shiki가 각 코드 토큰의 style에 CSS 변수를 넣어준다.
--shiki-light에 라이트 테마 색상, --shiki-dark에 다크 테마 색상이 들어간다.
CSS에서 전환해주면 끝이다.
[data-rehype-pretty-code-figure] code [style] {
color: var(--shiki-light);
}
.dark [data-rehype-pretty-code-figure] code [style] {
color: var(--shiki-dark);
}CSS 두 줄이면 코드 블록 색상이 테마에 맞게 알아서 바뀐다.
마치며
다크모드를 처음 적용해봤는데, CSS 변수로 색상을 관리하고 있었던 게 큰 도움이 됐다. 변수 값만 바꿔주면 사이트 전체가 반응하니까 컴포넌트를 하나하나 수정할 필요가 없었다.
그리고 Next.js 같은 SSR 환경에서는 서버/클라이언트 차이도 신경 써야 한다. 테마 정보는 클라이언트에서만 알 수 있기 때문에 hydration mismatch가 쉽게 발생하기 때문이다.