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

Next.js
2024-03-12

image

깃허브 링크

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

웹 스토리지의 문제점

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

image

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

image

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

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

해결 방법 1 : useEffect ( X )

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

image

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

/app/page.tsx

import dynamic from "next/dynamic"; const Main = dynamic(() => import("../components/Main"), { ssr: false }); export default function Home() { return <Main />; }
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> ); }
"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를 참조해 테마를 설정하는 방법이다.

이렇게 하면 깜빡임 없이 다크모드를 구현할 수 있다.

리액트는 브라우저 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> ); }
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> ); }

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

이 오류를 해결하려면, 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> ); }
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가지 타입으로 관리를 해야 하는데, 이렇게 하면 아래와 같이 로딩화면을 따로 구현해야 하기 때문이다.

image

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

[자세한 코드는 깃허브 참고]

관련 포스트

post thumbnail

Next.js + Vercel TTFB 문제 2

에서 Edge runtime으로 TTFB 문제를 해결했었는데, Express를 Next.js로 이미그레이션 후에는 Edge runtime을 사용할 수 없어서 다른 방법으로 문제를

2024-03-19
post thumbnail

데스크톱, 모바일 호환되는 가로 슬라이더 구현

Next.js 14, Tailwindcss, Typescript 터치 이벤트는 일반적으로 터치 스크린을 가진 디바이스에서 이용가능하다. 하지만, 터치 스크린을 갖는 디바이스를 포함

2024-03-12
post thumbnail

Next.js + Vercel TTFB 문제

로컬 환경에서는 애플리케이션의 TTFB(Time To First Byte)가 70~150ms정도 걸리는데, 배포 환경에서는 TTFB가 느리면 1500ms, 빠르면 400ms정도가

2024-03-12
post thumbnail

[번역] Next.js 14

Next.js 14는 다음과 같은 가장 중점으로 둔 릴리스이다. - Turbopack: App & Pages Router에서 5,000번의 테스트 통과 - 로컬 서버 시작이 53%

2024-03-12

도로모의 기술 블로그

# Contact : jyw966@naver.com

Copyright © doromo. All Rights Reserved.