Next.js에서 깜박임 없이 다크모드 적용하기
1. 먼저 알아두기
시스템 세팅을 통해 구현하는 방법
CSS 미디어 쿼리의 @media (prefers-color-scheme: dark)를 사용하는 것으로 가장 간단히 구현할 수 있습니다.
See the Pen dark-mode-1 by _______eungook (@_______eungook) on CodePen.
버튼을 통해 구현하는 방법
CSS의 클래스를 통해 간단히 구현할 수 있고, 초기값은 Window.matchMedia를 통해 확인할 수 있습니다.
See the Pen dark-mode-2 by _______eungook (@_______eungook) on CodePen.
2. 깜박임 없이 다크모드 적용하기
여기서부터 조금 신경을 써야 합니다.
위에서 확인했듯, 시스템 세팅이 아닌 버튼을 통해 다크모드를 적용하려면 최초 스크립트의 실행이 필요합니다.
이 때 스크립트의 실행이 느리면 화면에서 깜박임을 사용자가 느낄 수 있습니다.
먼저 말하자면 useEffect에서 호출하는 스크립트는 정말 느립니다!
스크립트 실행 속도 비교
Next.js, SSR 기준, 스크립트 실행 속도를 비교해보면 다음과 같습니다.
<script>- next/script,
<Script strategy="beforeInteractive">- next/script,
<Script strategy="afterInteractive">- 클라이언트 컴포넌트,
useEffect- next/script,
<Script strategy="lazyOnload">
즉 일반 <script>로 구현하는 것이 가장 실행이 빠릅니다. (약간 당연한 말이네요);;
코드로 구현해보면 다음과 같습니다.
// Next.js, layout.tsx
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" suppressHydrationWarning={true}>
<body>
<script>{`
(() => {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (isDark) {
document.documentElement.classList.add('dark');
}
)();
`}</script>
{children}
</body>
</html>
);
}
주의해야 할 점
먼저 <html>에 suppressHydrationWarning={true}가 필요합니다.
Element.classList.add를 하는 과정에서 React의 hydration mismatch가 발생할 수 밖에 없기 때문입니다.
그리고 <script>의 내용은 템플릿 리터럴로 작성해줘야 의도대로 동작합니다.
템플릿 리터럴 없이 작성할 경우 태그의 내용이 JSX의 문법으로 동작하게 되어
줄내림이 표현되지 않아 주석(//) 이후 내용이 실행되지 않거나,
중괄호({})가 의도와 달리 해석되는 등의 에러가 발생합니다.
실행속도 비교하는 방법
구글 크롬 개발자 도구의 퍼포먼스에 있는 녹화 기능을 통해 직접 확인해볼 수 있습니다.
확인하기 쉽도록 CPU 쓰로틀링을 x4 정도 걸고 녹화하는 것을 추천합니다.

<script>, DCL(=DOMContentLoaded)에서 적용된 경우.. 약 0.3초

<Script strategy="lazyOnload" />, L(=Window.onload)에서 적용된 경우.. 약 1초
3. notFound() 문제
사실 이게 까다롭습니다.
Next.js의 서버가 웹페이지에 대한 요청에 404 Not Found로 응답하는 경우는 두 가지가 있고,
이에 따라 페이지가 렌더되는 방식이 다릅니다.
여기서 이슈가 발생합니다.
3-1. 라우팅에 의한 404
Next.js의 라우팅에 해당하는 페이지가 없을 경우, 404와 함께 not-found.js가 렌더됩니다.
이 때 페이지는 SSR로 렌더되며, 따라서 2의 스크립트가 정상적으로 실행됩니다.
3-2. notFound()에 의한 동적인 404
하지만 next/navigation의 notFound()에서는 2의 스크립트가 정상 동작하지 않습니다.
이 때 페이지는 Hydration을 통해 CSR로 렌더되며, 따라서 <script> 태그의 내용이 실행되지 않습니다.
그래서 스크립트의 실행을 위해서는 next/script의 <Script strategy="afterInteractive">가 필요합니다.
여기서는 일반적인 Hydration과 달리 Node.appendChild()를 호출하기 때문에
// vercel/next.js, packages/next/src/client/script.tsx
function Script(props: ScriptProps): JSX.Element | null {
// 전략
if (strategy === 'afterInteractive') {
loadScript(props)
}
// 후략
}
const loadScript = (props: ScriptProps): void => {
// 전략
document.body.appendChild(el)
}
<script>가 렌더되면서, 동시에 실행도 보장이 됩니다. (조금 느리지만요.) 다행이네요!
최종 코드
// Next.js, layout.tsx
import Script from "next/script";
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" suppressHydrationWarning={true}>
<body>
<script src="/dark-mode.js" />
<Script src="/dark-mode.js" strategy="afterInteractive" />
{children}
</body>
</html>
);
}
// Next.js, /public/dark-mode.js
(() => {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (isDark) {
document.documentElement.classList.add('dark');
}
})();
참고
next/script의 배경에 대해서는 아래의 PR을 확인해주세요.
Script loader component by janicklas-ralph · Pull Request #18281 · vercel/next.js
CSR에서 <script>의 내용이 실행되지 않는 이유와,
next/script의 <Script>에서는 내용이 실행돠는 이유에 대해서는 아래의 HTML Standard를 확인해주세요.
HTML Standard: 4.12.1.1 Processing model
즉 insertedNode 과정을 거쳐야지만<script>의 내용이 실행됩니다.