로컬스토리지를 이용한 다크모드 구현

10일 전

이미지

소스 : https://github.com/yewonJin/nextjs13-darkmode-example

Next.js 13에서 웹 스토리지를 이용하여 다크모드를 구현했다.


웹 스토리지의 문제점

Next.js의 서버 컴포넌트에서는 브라우저 APIs를 이용할 수 없다. 즉, 웹 스토리지에 접근을 할 수 없기 때문에 서버 컴포넌트에서 로컬스토리지에 접근하면 아래와 같은 에러가 발생한다.

이미지

하지만 예상과 다르게, 클라이언트 컴포넌트에서도 웹 스토리지에 접근하면 같은 에러가 발생한다.

이미지

그 이유는 Next.js는 초기 페이지 로드를 최적화 하기 위해, 리액트 APIs를 사용하여 클라이언트와 서버 컴포넌트 모두에 대해 서버에서 정적인 HTML 프리뷰를 렌더링하기 때문이다.

즉, 클라이언트 컴포넌트에 대해 정적인 HTML 프리뷰를 렌더링을 할 때 localStorage를 참조하면 에러가 발생한다.


해결 방법 1 : useEffect (X)

이미지

이 문제를 해결하기 위해 useEffect를 사용하면 서버에서 렌더링한 HTML 프리뷰 (localStoragetheme 값을 참조할 수 없는)와 localStoragetheme 값을 참조하여 하이드레이션(?)한 페이지는 다르게 나타나므로 깜빡임 현상이 발생한다.

렌더링 흐름

위와 같이 깜빡임 현상이 발생하는 이유는 렌더링이 총 2번 일어나기 때문이다.

이미지

첫 번째 렌더링

서버에서 렌더링한 HTML 프리뷰 파싱 -> 스타일 다시 계산 -> 레이아웃 -> 페인트 -> 커밋


두 번째 렌더링

useEffect에서 스타일 다시 계산 예약 -> 스타일 다시 계산 -> 레이아웃 -> 페인트 -> 커밋

해결 방법 2 : 컴포넌트 SSR 해제하기 (X)

/app/page.tsx

import dynamic from "next/dynamic";

const Main = dynamic(() => import("../components/Main"), { ssr: false });

export default function Home() {
  return <Main />;
}


next/dynamic 을 이용하여 ssr을 해제하면, 정적인 HTML 프리뷰 렌더링을 막을 수 있다.

이렇게 하면 아래와 같이 컴포넌트에서 직접 localStorage에 접근할 수 있다.


/components/Main.tsx

"use client";

import Toggle from "./Toggle";
import Heading from "./Heading";
import useTheme from "@/hooks/useTheme";
import Content from "./Content";

export default function Main() {
  if (localStorage.theme === "dark") {
    document.getElementsByTagName("body")[0].setAttribute("data-theme", "dark");
  } else {
    document
      .getElementsByTagName("body")[0]
      .setAttribute("data-theme", "light");
  }

  const { handleThemeToggle } = useTheme();

  return (
    <main>
      <div id="top">
        <Toggle handleThemeToggle={handleThemeToggle} />
        <Heading />
      </div>
      <Content />
    </main>
  );
}

(위는 예시이다. 깔끔한 컴포넌트 작성을 위해 hooks이나 다른 곳에 작성하길 바란다.)


다만 SSR을 해제했기 때문에, 정적인 HTML 프리뷰를 받을 수 없어 초기 로딩이 느리고, SEO에 좋지 않다.


해결 방법 3 : script 태그를 이용한 Rendering Block (O)

브라우저의 렌더링 엔진은 HTML을 파싱하는 도중 script 태그를 만나게 되면, 파싱이 중단되고 자바스크립트 엔진에 제어권을 넘긴다. 이후 자바스크립트 파싱과 실행이 종료되면 렌더링 엔진으로 다시 제어권을 넘겨 다시 HTML을 파싱한다.


서버에서 렌더링한 HTML 프리뷰를 받고 이를 브라우저 렌더링 엔진이 파싱할 때 script 태그로 Rendering Block을 한 후 localstorage를 참조해 테마를 설정하는 방법이다. 이렇게 하면 깜빡임 없이 다크모드를 구현할 수 있다.


<script> 를 이용한 렌더링 블록

이미지이미지

<script>가 없을 때

이미지

리액트는 브라우저 DOM에서 innerHTML 사용을 위한 리액트의 대체 방법인 script 태그의 속성인dangerouslySetInnerHTML 을 제공한다. 이를 이용하여 __html 객체에 자바스크립트 코드를 전달하면 된다.

import "./globals.css";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const setThemeMode = `
    if (localStorage.theme === "dark") {
      document
        .getElementsByTagName("body")[0]
        .setAttribute("data-theme", "dark");
    } else {
      document
        .getElementsByTagName("body")[0]
        .setAttribute("data-theme", "light");
    }
  `;

  return (
    <html lang="kr">
      <body>
        <script dangerouslySetInnerHTML={{ __html: setThemeMode }} />
        {children}
      </body>
    </html>
  );
}

하지만 이렇게 하면 아래와 같은 에러가 발생한다.

이미지

이 오류를 해결하려면, body 태그에  suppressHydrationWarning={true} 를 추가하면 된다.

import "./globals.css";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const setThemeMode = `
    if (localStorage.theme === "dark") {
      document
        .getElementsByTagName("body")[0]
        .setAttribute("data-theme", "dark");
    } else {
      document
        .getElementsByTagName("body")[0]
        .setAttribute("data-theme", "light");
    }
  `;

  return (
    <html lang="kr">
      <body>
        <script dangerouslySetInnerHTML={{ __html: setThemeMode }} />
        {children}
      </body>
    </html>
  );
}

이렇게 하면 SSR을 하기 때문에 SEO에 좋다. 하지만 innerHTML이나 dangerouslySetInnerHTML 를 사용하면 XSS 공격에 취약하다는 단점이 있다. 이를 방지하기 위해 DOM Purify를 이용하여 안전하게 DOM을 조작하는 것을 추천한다.


다크모드 구현

localstorage 의 theme 값을 참조하여 theme 값에 따라 bodydata-theme 을 설정한다.

다크모드, 라이트모드 2가지 타입의 버튼을 모두 작성한다.


css에서 body[data-theme=”dark"]data-themedark일 때는 다크모드 전용 버튼만 화면에 표시하고, data-themelight일 때는 라이트모드 전용 버튼만 화면에 표시하는 식으로 구현했다.


이렇게 구현한 이유는 theme 값은 light, dark, ""의 3가지 타입으로 관리를 해야 하는데, 이렇게 하면 아래와 같이 로딩화면을 따로 구현해야 하기 때문이다.

이미지

또한 모드에 따라 달라지는 뷰 컴포넌트에 props로 theme 값을 전달하지 않아도 된다.