Next.js 에서 다크모드 구현하기

버건디·2023년 11월 24일
1

Next.js

목록 보기
49/52
post-custom-banner

CSR 환경에선 data-themeprefers-color-scheme 값을 사용하여 지정만 해주면 다크모드 상태가 구현이 가능하다.

:root {
    --background-color: #fff;
    --text-color: #121416d8;
    --link-color: #543fd7;
}

html[data-theme='light'] {
    --background-color: #fff;
    --text-color: #121416d8;
    --link-color: #543fd7;
}

html[data-theme='dark'] {
    --background-color: #212a2e;
    --text-color: #F7F8F8;
    --link-color: #828fff;
}

다크모드 적용 테마 흐름은 보통 이렇다.

  1. 초기 렌더링시 로컬스토리지 같은 저장소에서 테마 관련 된 값을 가져온다.
  2. 해당 테마 값에 일치하도록 전역에 theme 값을 적용한다.
import { useEffect, useState } from 'react';

export const useTheme = () => {
  const [darkTheme, setDarkTheme] = useState<boolean>(() => {
    const storedTheme = localStorage.getItem('theme');
    return storedTheme ? storedTheme === 'dark' : false;
  });

  const handleTheme = () => {
    const newTheme = !darkTheme;
    const theme = newTheme ? 'dark' : 'light';

    setDarkTheme(newTheme);

    localStorage.setItem('theme', theme);
  };

  useEffect(() => {
    if (darkTheme) {
      document.body.setAttribute('data-theme', 'dark');
    } else {
      document.body.removeAttribute('data-theme');
    }
  }, [darkTheme]);

  return { darkTheme, handleTheme };
};

위처럼 useEffect를 통해 토글마다 다크모드값이 적용되도록 할수 있다.

하지만 SSR 환경에서 위처럼 적용하고 새로고침을 하면 아래와 같은 문제가 발생한다.

잘 살펴보면 새로고침할때 흰색 화면이 렌더링 되었다가 후에 다크모드가 적용되는 문제가 발생한다.

이러한 문제를 FOUC(Flash of Unstyled Content) 라고한다.

FOUC는 웹 페이지가 로드될 때 스타일 시트가 적용 되기 전에 상태를 사용자에게 보여주는 현상을 의미한다.

다크모드 뿐만 아니라 웹 폰트(FOUT(ext))도 문제가 발생하는 경우가 있다.

이러한 문제는 왜 발생하는 걸까?

브라우저 렌더링 순서는 다음과 같다.

  1. HTML 파싱
  • HTML 파일을 파싱하여 DOM(Dom Object Model) 트리를 생성한다.
  1. CSS 파싱
  • HTML 안에 link태그로 연결된 css 파일이 있다면, 브라우저는 이를 요청하고 받아온다. 받아온 CSS 파일을 파싱하여 CSSOM(CSS Object Model) 트리를 생성한다. 후에 DOM 트리와 결합 되어 렌더 트리가 만들어진다.
  1. 렌더 트리 생성
  • DOM 트리와 CSSOM 트리를 결합하여 렌더 트리를 생성한다. 렌더 트리는 실제로 화면에 그려질 요소만을 포함하여 스타일 정보와 함께 요소들의 레이아웃을 결정한다.
  1. 레이아웃 계산
  • 렌더 트리에서 각 요소들의 크기와 위치를 계산한다. 이를 레이아웃 또는 리플로우라고 한다. 이러한 과정을 통해 브라우저는 각 요소의 정확한 크기와 위치를 결정한다.
  1. 페인팅
  • 레이아웃 계산이 완료 되면, 브라우저는 렌더트리를 순회하며 요소들을 화면에 그린다. 이 과정을 페인팅이라고 하고 요소들의 스타일 속성에 따라서 색상, 글꼴, 배경 등이 그려진다.
  1. 컴포지팅
  • 페인팅 된 요소들을 합성하여 최종적으로 사용자가 보게 되는 화면을 구성한다.

보통 FOUC는 2번 단계인 CSSOM 트리가 생성되기 전에 브라우저가 DOM 트리만을 바탕으로 콘텐츠를 먼저 렌더링할 때 발생한다.


useEffect 은 렌더링 후 (commit phase)에 실행 된다.

컴포넌트가 마운트된 후, 그리고 DOM 업데이트가 완료된 후에 다크모드를 적용시키는 것이기 때문에 FOUC가 발생할수 밖에 없다.

SSR 환경에서 이러한 문제를 해결하기 위해선, 초기 HTML에 상태 값을 주입해서 페이지 로드전에 테마를 적용해야한다.

이를 쿠키와 즉시 실행 함수로 구현해보았다.

- getThemeCookie

import { cookies } from 'next/headers';

type Theme = 'light' | 'dark';

export const getThemeCookieValue = () => {
  const cookieStore = cookies();
  const currentThemeObj = cookieStore.get('theme');
  let currentTheme: Theme = (currentThemeObj?.value as Theme) || 'light';
  if (!currentTheme) {
    currentTheme = 'light';
  }

  return currentTheme;
};

Next.js에서 제공되는 cookies라는 함수를 통해 theme 와 관련된 쿠키 값을 가져오고, 만약 값이 없다면 기본값으로 light를 제공한다.

그 후에 루트 레이아웃에서 쿠키 값을 받아오고 테마 관련된 즉시실행함수를 실행시켜준다.

- themeIIFE

export function themeIIFE(currentTheme: string): string {
  return `
            (function() {
                function setInitialColorMode() {
                    const currentTheme = "${currentTheme}";
                    if (currentTheme === "dark") document.body.setAttribute("data-theme", "dark");
                }
                setInitialColorMode();
            })()
        `;
}
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  const currentTheme = getThemeCookieValue();

  return (
    <html lang="en">
      <body suppressHydrationWarning>
        <script
          dangerouslySetInnerHTML={{
            __html: generateThemeScript(currentTheme),
          }}
        />

바로 스크립트를 삽입할수 있도록 하는 dangerouslySetInnerHTML를 사용하여 쿠키에서 읽어온 테마 값을 바로 적용시킬 수 있다.

하지만 이러면 아래 사진과 같은 경고 메세지가 나온다.

이는 저 dangerouslySetInnerHTML를 통해 삽입 된 스크립트가 HTML이 아닌 문자열로 인식 되기 때문이다.

이대로 테마 관련 스크립트를 삽입한다면 XSS 공격에 취약해지는 문제가 발생한다.

- 🔍 XSS 공격이란 ?

XSS 공격이란 해커가 악의적인 용도로 스크립트에 악성 코드를 삽입하는 것을 말한다.

위의 코드를 예로 들면 렌더링시에 바로 다크모드를 적용시키기 위해 IIFE 코드를 저 dangerouslySetInnerHTML 안에 넣어주었는데, 이 코드를 조작하여 악성 코드를 삽입할수도 있다는 것이다. (바닐라 자바스크립트로 따지면 innerHTML의 문제점으로 볼 수 있다.)

이러한 해커의 공격을 막으면서 돔 엘리먼트에 텍스트를 삽입하기 위해선 DOMPurify 라이브러리의 사용이 필요하다.

- 🔍 DOMPurify 란?

위에 언급한 해커의 공격을 막기 위해선 Sanitize(소독하다), 즉 삽입한 텍스트를 한번 필터링을 거치는 과정이 필요하다.

이 필터링, 소독의 역할을 DOMPurify이 해주는 것이다.

그래서 텍스트를 삽입하더라도 해커들의 공격으로부터 보호해준다.

- 사용하기

npm install dompurify @types/dompurify
import DOMPurify from 'dompurify';

const sanitizeHtml = (html: string) => {
  return DOMPurify.sanitize(html);
};

export default sanitizeHtml;

하지만 이렇게 사용하면

위의 같은 에러가 발생한다.

이 이슈 글에서도 똑같은 증상을 겪는 글을 발견할수 있는데, 이는 DOMPurify 가 클라이언트 측에서 실행이 되어야하는데 서버쪽에서 실행을 하려고 하기 때문이다.

DOMPurify 를 서버쪽에서도 실행시켜주기 위해서는 JSDOM을 따로 설치해주어야한다.

- 🔍 JSDOM이란 ??

jsdom은 JavaScript의 DOM 과 브라우저 환경을 모방한 라이브러리이다.
주로 Node.js 환경에서 사용되며, 서버쪽에서 HTML 문서를 파싱하고 DOM 요소를 조작할 수있다.

- JSDOM 설치

npm install jsdom @types/jsdom
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';

const sanitizeHtml = (html: string) => {
  return DOMPurify(new JSDOM('<!DOCTYPE html>').window).sanitize(html);
};

export default sanitizeHtml;

위의 방식대로 한다면 innerHTML 방식으로 스크립트를 삽입하더라도, 안전하게 코드를 주입할 수 있다.


dangerouslysetinnerhtml에 대해 다룬 글을 보면, 여기서는 DOMPurify 를 사용하는 방법도 권해주지만 다른 2가지 방법을 더 제시한다.

  1. JSX 를 먼저 사용해보려 노력한다.
  2. JSX 를 HTML으로 변환 해준다.

위의 방법들에 대한 구체적인 내용들을 아직 공부해본것은 아니지만, 한번 위의 두가지 방법도 고려해봄직 하다는 판단이 들었다.

profile
https://brgndy.me/ 로 옮기는 중입니다 :)
post-custom-banner

0개의 댓글