SSR 환경에서 다크모드 깜박임 현상 해결하기

By Yoon
·
thumbnail
블로그 다크모드 깜박임 현상 해결로 UX를 향상한 내용입니다. - 블로그 개발 후기 (6)

글을 시작하며

블로그 개발 이후에 지속해서 블로그를 관리하면서 다크모드 새로고침 시 발생하는 깜빡임 현상에 대해 불편함을 인지하고 개선한 내용입니다.

해결하기

원인과 해결책

테마값은 로컬 스토리지에 저장되어 있고, 기본값은 light입니다.

FCP(First Contentful Paint)는 웹 페이지가 로드되고 사용자가 볼 수 있는 첫 번째 콘텐츠가 화면에 페인트 되기 시작하는 시점을 말합니다. CSR(Client Side Rendering)은 이 FCP 단계 이후에 진행되기 때문에 FCP 이전에 진행되는 SSR(Server Side Rendering) 중에는 브라우저 API를 참조할 수 없고, 로컬 스토리지에 접근할 수 없습니다.

따라서, 다크모드로 설정되어 있어도 FCP 단계 동안 기본값인 밝은 화면을 보게 됩니다. 이는 화면이 깜박거리는 현상이 일어나고, 블로그 사용자들에게 불편함을 줄 수 있습니다.

따라서, 생각해 본 해결책은 다음과 같습니다.

  1. 브라우저의 렌더링 차단 리소스(render-blocking resources) 특성을 사용
  2. 로컬 스토리지에서 관리하던 테마값을 쿠키로 관리

저는 2번 방법보다는 1번 방법이 좀 더 적합하다고 생각했습니다. Server Component에서 쿠키를 set, get하면 SSG나 ISR이 아닌 SSR로 동작됩니다. 현재 포스트 글을 불러오는 페이지(홈 페이지)가 ISR로 구현되어 있어 적합하지 않다고 생각했습니다. 또한, Next.js 공식 문서도 SSR 다크모드가 1번 방법으로 구현되어 있고, 카카오 기술 블로그에도 이러한 방법을 소개하고 있습니다.

브라우저 렌더링 과정

FCP 전에 로컬 스토리지에 저장된 테마가 무엇인지 파악하면 깜박임 없이 다크모드 페이지를 렌더링할 수 있을 것 같습니다.

따라서, 브라우저 렌더링 과정을 알아야 합니다.

  1. HTML 파싱과 DOM 생성: 브라우저는 HTML 문서를 받아들여 파싱하여 DOM 트리를 생성합니다. DOM은 문서의 구조를 나타내는 트리 구조로, 각 요소는 노드로 표현되며, 문서의 구조를 계층적으로 표현합니다.
  2. CSS 파싱과 CSSOM 생성: HTML 파싱과 동시에 브라우저는 CSS 파일을 다운로드하고 파싱하여 CSSOM을 생성합니다. CSSOM은 CSS 규칙을 나타내는 객체 모델로, 각 요소에 적용될 스타일 규칙을 정의합니다.
  3. 렌더 트리 생성: DOM과 CSSOM이 모두 생성되면, 브라우저는 이를 결합하여 렌더 트리를 생성합니다. 렌더 트리는 화면에 표시될 요소들의 구조를 나타내며, 실제로 화면에 그려지는 요소들의 집합입니다.
  4. 리플로우와 리페인트: 렌더 트리가 생성되면, 브라우저는 각 요소의 크기와 위치를 계산하여 레이아웃을 수행합니다. 이 과정을 리플로우(Reflow)라고 합니다. 그리고 레이아웃이 완료되면 요소들이 화면에 그려지는 과정인 리페인트(Paint)가 이루어집니다. 이 과정을 통해 사용자가 볼 수 있는 최종 화면이 생성됩니다. 레이아웃에 변경이 없으면 리페인트만 실행된다.

해결 전

Context API로 전역적으로 테마 상태를 관리합니다. 사용자가 다크 모드를 토글 하면 HTML 태그에 다크 모드를 위한 dark 클래스가 추가되고 테마값은 로컬 스토리지에 저장됩니다.

/* global.css */
.dark {
  // ...
}
// app/_context/ThemeContext/index.tsx
type ThemeContextState = {
  isDarkMode: boolean;
  toggleDarkMode: VoidFunction;
};

export const ThemeContext = createContext<ThemeContextState | null>(null);

const key = "theme";

export const ThemeProvider = ({ children }: PropsWithChildren) => {
  const [isDarkMode, setIsDarkMode] = useState<boolean>(false);

  // 테마 초기 설정
  useEffect(() => {
    const theme = localStorage.getItem(key);
    if (!theme) {
      localStorage.setItem(key, "light");
    } else {
      setIsDarkMode(theme === "dark");
    }
  }, []);

 // html 태그 id 값으로 class를 추가해줌 -> 해결 후 해당 코드 삭제
  useEffect(() => {
    const htmlTag = document.getElementById("html");
    if (!htmlTag) {
      throw Error("html 태그가 없습니다.");
    }
    if (isDarkMode) {
      htmlTag.classList.add("dark");
    } else {
      htmlTag.classList.remove("dark");
    }
  }, [isDarkMode]);

  // 테마 토글 시 -> 해결 후 해당 코드 수정 및 추가
  const toggleDarkMode = useCallback(() => {
    const newDarkMode = !isDarkMode;
    setIsDarkMode(newDarkMode);
    localStorage.setItem(key, newDarkMode ? "dark" : "light");
  }, [isDarkMode]);

  const theme = useMemo(
    () => ({
      isDarkMode,
      toggleDarkMode,
    }),
    [isDarkMode, toggleDarkMode],
  );

  return (
    <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
  );
};

해결 후

브라우저 렌더링 차단 리소스script 태그와 dangerouslySetInnerHTML 속성을 이용해 FCP 이전에 테마값을 불러와 설정합니다.

// layout.tsx

const themeInitializerScript = `
  const theme = localStorage.getItem("theme");
  document.documentElement.setAttribute("data-theme", theme);
  `;

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ko">
      <head>
        <ThemeProvider>
          <body>
            // 브라우저 렌더링을 차단하고, 로컬 스토리지에서 테마값을 가져온다.
            // html 태그에 data-theme 속성을 설정한다.
            <script
              dangerouslySetInnerHTML={{ __html: themeInitializerScript }}
            />
           // 웹 컨텐츠
          </body>
        </ThemeProvider>
      </head>
    </html>
  );
}
/* global.css */
[data-theme="dark"] {
  // ...
}
// app/_context/ThemeContext/index.tsx
const toggleDarkMode = useCallback(() => {
  const newDarkModeState = !isDarkMode;
  setIsDarkMode(newDarkModeState);
  const newTheme = newDarkModeState ? "dark" : "light";
  localStorage.setItem(key, newTheme);
  document.documentElement.setAttribute("data-theme", newTheme);
}, [isDarkMode]);

글을 마치며

배운점

  1. 브라우저의 렌더링 과정
  2. SSR에서의 다크모드 구현 시 깜박임 원인과 해결

마무리

지금까지 블로그 다크모드 깜박임 현상 해결로 UX를 향상한 내용입니다.

읽어주셔서 감사합니다.

YoonLog 깃허브 레포