NextJS 블로그 만들기 3: 다크 모드
2025.06.17,
8 min.
기존에 제 블로그는 라이트 모드인 NextJS의 blog-start를 베이스로 구현하였습니다.
하지만 개인적으로 어두운 화면이 눈이 편하기 때문에 데스크탑이나 노트북 모두 시스템 기본 설정을 다크 모드로 설정하여 사용하는 것을 선호합니다.
따라서 이번 글에서는 다크 모드를 추가한 내용을 다뤄보고자 합니다.
PandaCSS의 Multi-Themes
블로그를 구현하며 스타일링 라이브러리로 PandaCSS를 사용했는데요,
PandaCSS는 Multi-Themes를 통해 여러 색상 모드에 대한 설정을 적용할 수 있습니다.
먼저, 아래와 같이 PandaCSS config 파일에 색상 모드에 대한 조건을 추가합니다.
해당 config를 통해 PandaCSS는 conditions
에 설정된 조건을 보고 HTML
의 data-color-mode 데이터 속성을 확인합니다.
// panda.config.ts
const config = {
// ...
conditions: {
light: '[data-color-mode=light] &',
dark: '[data-color-mode=dark] &',
},
}
그리고 해당 속성에 설정된 값과 조건이 일치하는 색상 모드에 대한 스타일링을 적용해줍니다.
조건에 포함된 각 색상 모드에 대한 스타일링은 semanticTokens
설정을 통해 간단하게 적용할 수 있습니다.
스타일 속성 값을 value 객체로 대체하며, 이 객체 내 base
와 _${theme}
속성에 값을 넣어두기만 하면 됩니다.
// panda.config.ts
const config = {
// ...
theme: {
semanticTokens: {
colors: {
primary: {
value: {
base: "#fdfdfd",
_dark: "#121212"
}
}
}
}
}
}
설정 유지하기
저뿐만 아니라 혹시나 다른 사용자가 블로그에 들어와서 원하는 색상 모드를 설정하고 이후에 재접근하는 경우에도 색상 모드가 유지되도록 설정하고 싶었습니다.
가장 먼저 고려했던 방식은 두가지였습니다.
1. Local Storage
2. Cookie
우선, 2가지 방식 모두 만료 시간 이전까지는 선택한 설정이 유지될 것이라 생각하였습니다.
하지만 Local Storage의 경우, 서버 컴포넌트에서 접근이 어렵고, 클라이언트에서 현재 페이지에 적용될 색상 모드를 계산한다면 디폴트로 설정된 모드가 잠시 보였다가 설정되는 깜빡임 문제 때문에 next/headers
의 cookies를 활용하여 첫 구현을 진행하였습니다.
시행착오: middeware + cookie
먼저, cookie에 현재 모드에 대한 내용을 담아 응답을 내려주어야 했습니다.
이때 2가지 방법을 고려하였습니다.
1. Server Action
2. Middleware
Server Action
은 Next 13에서 실험 기능을 거쳐 14부터 Stable한 기능으로 등장하였지만, 한번도 다뤄보지 않아 사용해보려고 했지만, 서버 컴포넌트에서는 액션을 통해 쿠키에 값을 설정하면 아래와 같은 에러가 발생했습니다.
Error: Cookies can only be modified in a Server Action or Route Handler.
아무래도 서버 컴포넌트는 서버에서 컴포넌트를 렌더링하고 스트리밍을 통해 클라이언트에게 내려주고 주입하는 형식이라 타이밍 이슈로 설정이 안되지 않을까 생각하며 이유를 찾아보았습니다.
하지만 공식 문서에도 정확한 이유가 존재하지는 않는 것 같았고, 아래 두 내용이 가장 근접한 이유라고 생각했습니다.
- 서버 컴포넌트가 스트리밍될 때, 이미 본문 일부가 전송되었을 수 있기에 쿠키 설정에 어려움이 존재한다. (ian님 블로그)
- Action은 클라이언트가 서버의 함수를 호출하는 방법이고, 스트리밍 때문에 서버 컴포넌트 렌더링 시 쿠키 설정은 불가능하다. (NextJS discussions에서 Vercel 엔지니어님의 답변)
따라서 middleware
를 통해 구현하는 방향으로 결정하였습니다.
구현 방식은 아래와 같았습니다.
-
middleware를 통해 모든 경로에 대해서 쿠키에 색상 모드에 대한 정보를 확인하고, 없다면 기본 설정 값인 라이트 모드를 주입한다. (middleware.ts)
-
그리고 모드 변경 버튼을 통해 원하는 모드로 설정할 수 있도록 색상 모드의 상태를 저장하고 관리할 전역 컨텍스트를 추가하고, 현재 색상 모드 값을 훅을 통해 제공한다. (contexts/color-mode.tsx)
-
이후
layout
에서 쿠키에 포함된 값을 확인하여 HTML의data-color-mode
속성에 현재 설정된 색상 모드의 값을 넣어준다. (app/layout.tsx)
시스템 기본 설정 가져오기 중 문제 발견
위 구현을 통해 모드 변경 버튼을 통해 원하는 색상 모드로 변경하면 다시 진입해도 설정이 유지되었습니다.
하지만 설정을 유지한다는 기능에만 초점을 두어 사용자의 시스템 기본 설정을 가져와 디폴트로 설정해주어야 한다는 점을 잊고 있었죠..
아래 코드를 통해 현재 사용자의 기본 설정이 다크 모드인지 확인하고, 이에 맞는 설정을 진행해야 했습니다.
window.matchMedia("(prefers-color-scheme: dark)")
이 또한 window
객체에 접근해 클라이언트 사이드에서 진행해야 하기 때문에 현재 구현된 내용으로는 깜빡임 문제가 다시 발생하였습니다.
그리고 모든 요청마다 비동기로 쿠키의 내용을 읽어 색상 모드를 적용해서인지, 배포 환경의 메인 페이지에서 각 게시글을 prefetch하며 병목이 발생하는 것 같았습니다.
문제 해결
이러한 이유로 middleware
와 cookie
로 구현된 설계를 local storage
로 변경하기 시작하였습니다.
middleware를 제거하고 cookie에서 읽고 쓰던 내용을 local storage로만 변경하면 되었기에 크게 어렵지 않았습니다.
const COLOR_MODE_KEY = "color-mode" as const;
interface ColorModeProviderProps {
children: ReactNode;
}
export const ColorModeProvider = ({ children }: ColorModeProviderProps) => {
const [colorMode, setColorMode] = useState<ColorModeType>(() => {
if (typeof window === "undefined") return "light";
const savedMode = localStorage.getItem(COLOR_MODE_KEY);
if (savedMode === "dark" || savedMode === "light") {
return savedMode;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
});
const toggleColorMode = () => {
const next = colorMode === "light" ? "dark" : "light";
setColorMode(next);
localStorage.setItem(COLOR_MODE_KEY, next);
document.documentElement.setAttribute("data-color-mode", next);
};
return (
<ColorModeContext.Provider value={{ colorMode, toggleColorMode }}>
{children}
</ColorModeContext.Provider>
);
};
하지만 이렇게만 구현한다면, 페이지 진입 시 설정된 색상 모드가 바로 적용되지 않습니다.
이를 해결하기 위해 local storage나 session storage가 변경될 때 발생하는 storage 이벤트를 활용해볼까 하였지만, 이벤트가 발생한 window에서는 해당 이벤트가 발생하지 않으며, useEffect로 렌더링이 완료된 후에 이벤트 리스너가 등록되기 때문에 큰 의미가 없을 것 같다고 판단하였습니다.
따라서 script
태그를 통해 local storage에 값이 있는지 확인하고 없다면 시스템 설정을 따르는 내용을 HTML에 직접 주입하면 어떨까라는 생각을 하게 되었습니다.
const savedMode = localStorage.getItem('color-mode');
if (savedMode === 'dark' || savedMode === 'light') {
document.documentElement.setAttribute('data-color-mode', savedMode);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-color-mode', 'dark');
}
실행할 내용이 간단하기도 하고, HTML을 파싱하다 script를 만나면 파싱을 멈추고 script를 실행하기 때문에 화면이 구성되기 전에 가장 직관적으로 문제를 해결할 수 있는 방법이라고 판단하였습니다.
export const ColorModeProvider = ({ children }: ColorModeProviderProps) => {
// ...
return (
<ColorModeContext.Provider value={{ colorMode, toggleColorMode }}>
<script
dangerouslySetInnerHTML={{
__html: script,
}}
/>
{children}
</ColorModeContext.Provider>
);
};
물론, 이렇게 처리하게 되면 layout.tsx
가 실행되며 서버 컴포넌트를 만들어 스트리밍할 때 일치하지 않는 부분이 존재하기에 하이드레이션 오류가 발생하게 됩니다.
이에 대해 저는 기능이 아닌 UI 측면에서 발생한 불일치이기 때문에 "이 불일치는 의도된 것이니 무시해줘!" 라는 의미로 suppressHydrationWarning
속성을 추가해 주었습니다.
마무리
이렇게 여러 구현 끝에 다크 모드를 구현하였습니다.
디프만이 끝나고 가장 재미있게 몰입해서 구현한 부분인 것 같아요.
PandaCSS에서 다크 모드를 어느 정도 지원했기에 금방 끝날줄 알았지만, 색상 모드 설정 유지와 시스템 기본 설정을 가져오는 부분에서 여러 시행착오를 겪으면서 좀 오래 걸린 것 같습니다.
하지만 그 과정에서 여러 번 시도를 하며 얻게 된 지식도 생겨 뿌듯하네요 :)
초반에는 motion
라이브러리로 모드 변경 버튼을 만들면서 애니메이션을 어떻게 넣을까 고민하며 설레는 마음으로 시작했는데, 중간에 이슈들을 거치면서 초반 설계에 대한 중요성을 다시 느낄 수 있었던 경험이었습니다.
다음 블로그 제작기는 sitemap
과 rss
에 대한 내용을 고민하고 있는데,
현재 구글 서치 콘솔에서 rss를 읽어오지 못하고 있어서, 이 부분만 빠르게 해결하고 작성해보겠습니다!