- #Blog
- #Next.js
- #DarkMode
블로그(번외) - Next 13버전에 다크모드 곁들이기
Prolip
2024-01-27
결론부터 말씀 드리자면 localStorage에 저장하지 말고 캐시에 저장하세요.
시작…
해당 블로그에 토클 버튼을 이용한 다크모드 기능을 구현하려 한다.. Next.js는 13버전을 사용하고 있으며 CSS는 Tailwind를 사용 중! 그래서 어떻게 구현하나요??
localStorage
다크모드 기능 자체는 그다지 어렵지 않다. 토글을 이용한 state 값 전환으로 다크모드, 라이트모드 전환. 하지만 재접속, 새로고침이 발생한다면? state는 초기화 되고 당연히 사용자는 이전의 테마를 잃게 된다. 그렇기에 localStorage를 이용해 사용자의 theme 정보를 저장할 것이다. 이는 사실상 반영구적으로 사용할 수 있게 되는 것이다. 그럼 대략적인 기능은 어떻게 이루어질까?
- 사용자가 버튼을 클릭한다.
- 버튼이 토글될 때 다크모드 상태를 나타내는 state를 업데이트한다.
- 해당 state를 이용해 document.body에 dark라는 class를 추가하거나 삭제한다.
- 그 과정에 localStorage에 해당 theme 정보를 저장해 사용자가 재접속 시에도 상태를 저장할 수 있도록 한다.
- class에 dark가 있을 때 tailwind를 이용해 dark 모드에 맞는 UI를 표시한다.
이렇게 구성하려고 한다!
어떻게 구현할까요?
DarkModeContext.tsx
일단 나는 context API를 이용해 DarkModeProvider를 만들었다.
onClick 이벤트, useState, useEffect 등과 같은 브라우저에서 동작하는 리액트 hook을 사용하기 위해 클라이언트 컴포넌트로 선언하자.
"use client"; import { createContext, useContext, useEffect, useState } from "react"; const DarkModeContext = createContext({ darkMode: false, toggleDarkMode: () => {}, }); export function DarkModeProvider({ children }: { children: React.ReactNode }) { const [darkMode, setDarkMode] = useState(false); const toggleDarkMode = () => { setDarkMode(!darkMode); updateDarkMode(!darkMode); }; useEffect(() => { const isDark = localStorage.theme === "dark" || (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches); setDarkMode(isDark); updateDarkMode(isDark); }, []); return ( <DarkModeContext.Provider value={{ darkMode, toggleDarkMode }}> {children} </DarkModeContext.Provider> ); } function updateDarkMode(darkMode: boolean) { if (darkMode) { document.documentElement.classList.add("dark"); localStorage.theme = "dark"; } else { document.documentElement.classList.remove("dark"); localStorage.theme = "light"; } } export const useDarkMode = () => useContext(DarkModeContext);
내가 위에서 설명한 5가지 과정을 위한 기능들이 들어있다. 토글을 이용한 darkMode state 전환, 재접속 시에 해당 상태를 불러오기 위한 useEffect 부분 등.. 이제 이 프로바이더를 이용해 layout에서 컴포넌트를 감싸주어야 한다.
Layout.tsx
<DarkModeProvider> <Header /> <main>{children}</main> <Footer /> </DarkModeProvider>
이렇게.. 그럼 이 DarkModeProvider에 감싸진 하위 컴포넌트에서 우리가 만든 Context에 접근할 수 있다.,
Header.tsx
이제 다크모드를 토글할 버튼을 Header 컴포넌트에 추가해보자!
const { darkMode, toggleDarkMode } = useDarkMode(); return ( <button onClick={toggleDarkMode}> {darkMode && <p>Dark</p>} {darkMode && <p>Light</p>} </button> )
이렇게 간단하게 사용 가능하다!
끝났나요??
아니요. 실패했습니다. 아니 정확히 말하자면 성공은 했으나 반만 성공했습니다. 원하는 기능은 모두 구현했습니다만, 다크모드를 적용한 상태에서 새로고침을 하면 화면이 하얗게 깜빡인 후에 다시 다크모드로 돌아가 눈에 보기 싫기 때문입니다..
왜일까요??
Next는 기본적으로 SSR(Server Side Rendering)로 작동합니다.
그렇다면 렌더링이 이루어질 당시 브라우저 API를 참조할 수 없게 됩니다.
그럼 우리가 위에서 작성한 코드를 확인해봅시다.
우리는 기본적으로 darkMode를 false로 설정해 초기 테마 값을 라이트모드로 설정하고 있습니다.
useEffect는 브라우저 API로 참조할 수 없기 때문에 기본 값인 라이트모드로 화면을 렌더한 후 브라우저 API인 useEffect가 실행 되며 localStorage에 접근해 다크모드로 업데이트 하는 것입니다.
종합적으로 이런 이유로 깜빡이는 것입니다.. 미리 알았다면 참 좋았을텐데 역시 삽질은 기분 좋습니다.
해결책
을 준비하기 이전에 알아두면 좋을 사전 지식들이 있습니다. 바로..
렌더링
렌더링은 서버에서 HTML 파일을 받아 브라우저에 표시하는 과정입니다.
- 브라우저는 서버로부터 요청을 통해 HTML 문서를 받는다.
- HTML을 파싱해 DOM, CSSOM 트리를 생성한다.
- 생성된 DOM, CSSOM 트리를 이용해 렌더링 트리 생성
- 레이아웃
- 형성된 트리를 토대로 기기의 뷰포트 내 객체들에게 노드들의 정확한 위치와 크기를 계산하는 과정
- 페인팅
- 위 레이아웃을 거치면 렌더링 엔진은 각 요소의 위치 및 크기를 알게 된다. 이후 렌더링 엔진은 페인트 이벤트를 발생 시켜 렌더링 트리를 화면에 그린다.
이렇게 간단하게 정리해 볼 수 있습니다.
그래서 왜 설명한 겁니까?
2번 과정의 파싱을 조금만 더 자세히 들여다보자..
Parsing
HTML 파일을 읽어 마크업 단위로 쪼개는 과정입니다.
그 과정에서 <link />, <style />, <script /> 태그 등을 만나면 CSS, JS 모두 함께 파싱합니다.
그런데 여기서 JS를 만나게 된다면 렌더링 엔진은 HTML 파싱을 잠시 멈추게 됩니다.
이유는 JS는 DOM 조작이 가능하기 때문에 DOM 트리에 영향을 주기 때문입니다.
그렇기 때문에 JS를 만나게 되면 JS 파싱이 끝난 후 HTML 파싱을 이어서 진행합니다.
이처럼 JS는 Parse blocking resource에 속해, 이를 막기 위해선 script 태그를 body의 마지막에 넣거나 defer 속성과 함게 사용합니다. 그렇다면 …!!
진짜 해결책
이제 위에서 설명한 parsing 과정에 우리가 이용할 방법이 있다는 것을 알게 되었습니다.
HTML Blocking
우리는 이제 script 태그를 만나 JS를 파싱하는 과정에 HTML 파싱이 중단되는 것을 알았습니다.
그렇다면 localStorage에 접근해 theme 정보를 가져와 다크모드를 업데이트하는 코드를 script 태그에 담아 삽입하면 되겠군요!
Next 12버전의 경우 _document 파일을 만들어 script 태그를 넣었습니다만 주인장은 13버전을 사용 중입니다.
13버전의 경우 html 태그가 어디 있는지 아십니까.. 바로 상위 layout 파일에 있습니다. 그렇다면 우리는 위 useEffect라는 브라우저 API에서 사용 중이던 코드를 script 태그로 적절히 옮겨 사용하면 됩니다.
const setThemeMode = ` const isDark = localStorage.theme === "dark" || (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches); if (isDark) { document.documentElement.classList.add("dark"); localStorage.theme = "dark"; } else { document.documentElement.classList.remove("dark"); localStorage.theme = "light"; } `;
<html lang='en'> <body> <script dangerouslySetInnerHTML={{ __html: setThemeMode, }} /> <DarkModeProvider> <Header /> <main>{children}</main> <Footer /> </DarkModeProvider> </body> </html>
잠깐..!!
아니 왜 저렇게 생겼나요??
문자열화 시키는 이유
해당 script는 번들링이 이루어진 자바스크립트를 불러오기 전에 실행된다. 그렇기에 번들링 된 값을 표시한다면 제대로 동작하지 않는다.
dangerouslySetInnerHTML이 뭡니까?
dangerouslySetInnerHTML
은 브라우저 DOM에서 innerHTML
을 사용하기 위한 React의 대체 방법입니다. 일반적으로 코드에서 HTML을 설정하는 것은 사이트 간 스크립팅 공격에 쉽게 노출될 수 있기 때문에 위험합니다. 따라서 React에서 직접 HTML을 설정할 수는 있지만, 위험하다는 것을 상기시키기 위해 dangerouslySetInnerHTML
을 작성하고 __html
키로 객체를 전달해야 합니다.
해결했나요?
아니요? ㅋㅋ
Next.js는 SSR로 동작한다고 아까 말씀 드렸습니다… 그렇다면 서버에서 생성된 컴포넌트, DarkModeProvider는 클라이언트 컴포넌트로 둘의 클래스명이 서로 달라지는 것입니다.
suppressHydrationWarning..
공식 문서에 따르면 SSR을 사용하는 경우, 일반적으로 서버와 클라이언트가 다른 내용을 렌더링할 때 경고가 표시됩니다.. 위와 같이 말입니다.. 타임 스탭프 같이 서버와 클라이언트 상에서 서로 다른 값을 일치시키는게 매우 힘들거나 불가능하다고 합니다. 하지만 위 옵션을 html 태그에 true로 설정하면 React는 어트리뷰트와 그 엘리먼트 내용의 불일치에 대해 경고하지 않습니다. 주의사항… 남용하면 안된답니다.
<html lang='en' suppressHydrationWarning> <body> <script dangerouslySetInnerHTML={{ __html: setThemeMode, }} /> <DarkModeProvider> <Header /> <main>{children}</main> <Footer /> </DarkModeProvider> </body> </html>
진짜로 해결 됐나요??
네 됐습니다. 이제 새로고침 시에도 화면 깜빡임 이슈가 없습니다만…..
토글 버튼의 경우 결국 context에서 isDark를 전달 받기 때문에 새로고침 시에 모양이 바뀝니다..
4시간의 삽질 끝에 결국 행복한 결말을 맞이했습니다만, 캐시를 이용한 다크모드를 구현하기로 마음 먹고 이번 포스팅을 마칩니다.
결론
SSR 환경에선 그냥 캐시로 다크모드를 구현하자. 아 그리고 next-themes라는 라이브러리가 있는 거 같은데 다크모드를 라이브러리에 의존하고 싶지 않아서 그냥 했습니다.
사실 써봤음. 근데 똑같다.