새로고침 시 화면 깜빡임(FOUC) 처리하기

긴가민가·2024년 9월 22일
0

문제 사항 해결기

목록 보기
9/9
post-thumbnail
post-custom-banner

🍀 FOUC(Flash of Unstyled Content) 해결에 대한 트러블 슈팅 과정입니다.

깜빡거리니까 킹받네

현재 토이 프로젝트에서 다크 모드 기능을 구현하고 있습니다.
변경은 잘 되는데, 왜 새로고침하면 깜빡거리지..??

깜빡거리는 이유가 뭘까?

현재 필자에 처리 로직은 다음과 같습니다.

최초 로드 시 useEffect에서 localStorage에 저장된 테마 정보를 가져와 설정합니다. 만약 저장되지 않았다면 시스템 테마 정보로 설정합니다.
그리고 테마 스위치를 클릭하면 localStorage에 저장합니다.

// useTheme.ts
import useThemeStore from "@/store/theme/theme";
import { THEME_TYPE } from "@/utils/constants";
import { ChangeEvent, useEffect } from "react";

export default function useTheme() {
  const theme = useThemeStore((state) => state.theme);
  const { setTheme } = useThemeStore((state) => state.actions);

  /**
   * @description 테마 변경 시 이벤트
   */
  const toggleHandler = (event: ChangeEvent<HTMLInputElement>) => {
    const changedTheme = event.target.checked ? THEME_TYPE.DARK : THEME_TYPE.LIGHT;

    // 1. 변경된 테마로 설정
    document.documentElement.setAttribute("data-theme", changedTheme);
    // 2. 로컬 스토리지 설정
    localStorage.setItem('theme', changedTheme);
    // 3. 전역 상태 저장
    setTheme(changedTheme);
  };

  /**
   * @description 최초 로드 시 스토리지 저장 값으로 테마 변경하기
   */
  useEffect(() => {
    const isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; // 시스템 테마 확인
    const storageTheme = localStorate.getItem('theme') || isDark ? 'dark' : 'light';

    document.documentElement.setAttribute("data-theme", storageTheme);
    setTheme(storageTheme);
  }, [setTheme]);

  return {
    isDark: theme === THEME_TYPE.DARK,
    isLight: theme === THEME_TYPE.LIGHT,
    theme,
    toggleHandler,
  };
}

깜빡거리는 이유는 바로 useEffect 때문입니다.
useEffect는 DOM parsing이 완료되고, layout/paint 과정까지 완료된 후에 비동기로 동작합니다.
Mac 기준으로는, 자동으로 시스템 테마로 그려지고 있습니다. 그 후 useEffect가 실행되면서 repaint되어 깜빡이는 현상이 발생합니다.

원하는 방향

필자는 원하는 방향은 이렇습니다.

client storage에 테마 정보가 저장되지 않은 경우
-> 시스템 테마
client storage에 테마 정보가 저장된 경우 (토글로 테마 변경 시)
-> 저장된 테마

useLayoutEffect를 사용해볼까?

??? : useLayoutEffect는 paint 전에 실행되니까 되지 않을까??

처음에는 useLayoutEffect를 사용해서 해봐야겠다! 라고 생각했었답니다.
과연 될까요??😏

똑같네요;ㅎㅎ 잘 생각해보니 바보같은 생각이었어요..

안 되는 이유

next.js는 SSR 방식이기 때문에 정적 html 파일이 로드되는 시점에 이미 PC의 시스템 테마를 우선순위로 생각하고 있고, bundle된 script 파일이 로드되어야 hook(useEffect/useLayoutEffect 등)이 실행되기에 useEffect나 useLayoutEffect나 결과의 차이는 없습니다.

그럼 어쩌지?

일단 정적 html이 만들어질 때 테마를 설정하면 좋을 것 같습니다.
localStorage나 sessionStorage는 client에서만 가능하니.. Cookie에 테마 정보를 저장하면 접근이 가능하겠네요!

Cookie를 이용해 정적 html에 설정하기

참고로 daisyUI 디자인 프레임워크 사용 중입니다.ㅎㅎ

// app/layout.tsx
import "@/assets/styles/globals.css";
import Header from "@/components/common/Header";
import { cookies } from "next/headers";
import { Inter } from "next/font/google";
import type { ReactNode } from "react";

const inter = Inter({ subsets: ["latin"] });

interface IRootLayout {
  children: ReactNode;
}

export default function RootLayout({ children }: Readonly<IRootLayout>) {
  // 쿠키 정보👇
  const cookieStore = cookies();
  const theme = cookieStore.get("theme")?.value;

  return (
    {/* 테마 설정👇 */}
    <html lang="ko" data-theme={theme}>
      <body className={inter.className}>
        <div className="h-screen min-h-screen w-screen">
          <Header />
          {children}
        </div>
      </body>
    </html>
  );
}

그리고 이전에 useTheme hook에서 토클 이벤트 부분 로직을 수정해줍니다. (localStorage -> cookie)

Cookie parsing 귀찮아서.. 라이브러리 설치했습니다. js-cookie :)

// useTheme.ts
import useThemeStore from "@/store/theme/theme";
import { THEME_TYPE } from "@/utils/constants";
import Cookies from "js-cookie";
import { ChangeEvent, useEffect } from "react";

export default function useTheme() {
  const theme = useThemeStore((state) => state.theme);
  const { setTheme } = useThemeStore((state) => state.actions);

  /**
   * @description 테마 변경 시 이벤트
   */
  const toggleHandler = (event: ChangeEvent<HTMLInputElement>) => {
    const changedTheme = event.target.checked ? THEME_TYPE.DARK : THEME_TYPE.LIGHT;

    // 1. 변경된 테마로 설정
    document.documentElement.setAttribute("data-theme", changedTheme);
    // 2. 쿠키 설정
    Cookies.set('theme', changedTheme);
    // 3. 전역 상태 저장
    setTheme(changedTheme);
  };

  /**
   * @description 최초 로드 시 스토리지 저장 값으로 테마 변경하기
   */
  useEffect(() => {
    const isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; // 시스템 테마 확인
    const storageTheme = Cookies.get('theme') || isDark ? 'dark' : 'light';

    document.documentElement.setAttribute("data-theme", storageTheme);
    setTheme(storageTheme);
  }, [setTheme]);

  return {
    isDark: theme === THEME_TYPE.DARK,
    isLight: theme === THEME_TYPE.LIGHT,
    theme,
    toggleHandler,
  };
}

쿠키로 처리하면 해결되네요 :)

아직 한 발 남았다...

하지만 아직 문제가 완벽히 해결된 것이 아니었습니다..

cookie에 테마 정보가 없는 완전 초기에는 data-theme 값에 undefined가 들어가게 됩니다.
기본 테마를 임의로(예를 들어 light) 설정하게 되면 결국 똑같은 문제(FOUC)가 발생될테고, 시스템 테마 정보를 가져와 설정하고 싶지만 정적 html을 만들 때는 window 객체를 접근할 수 없어 불가능하네요.ㅠㅠ

HTML Blocking

브라우저가 HTML을 parsing 하는 과정에서, script 태그를 만나면 해석이 완료될 때까지 parsing을 중단(blocking)합니다. 이 시점에는 client이기 때문에 window 객체에 접근을 할 수 있습니다.

이 방식을 적용해봐야겠어요!

// app/layout.tsx
import "@/assets/styles/globals.css";
import Header from "@/components/common/Header";
import { cookies } from "next/headers";
import { Inter } from "next/font/google";
import type { ReactNode } from "react";

const inter = Inter({ subsets: ["latin"] });

interface IRootLayout {
  children: ReactNode;
}

export default function RootLayout({ children }: Readonly<IRootLayout>) {
  // 쿠키 정보👇
  const cookieStore = cookies();
  const theme = cookieStore.get("theme")?.value;

  return (
    <html lang="ko" data-theme={theme}>
      {/* 이 부분👇 */}
      {!theme && (
        <head>
          <script
            dangerouslySetInnerHTML={{
              __html: `
              const isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
              document.documentElement.setAttribute("data-theme", isDark ? 'dark' : 'light');
            `,
            }}
          />
        </head>
      )}
      <body className={inter.className}>
        <div className="h-screen min-h-screen w-screen">
          <Header />
          {children}
        </div>
      </body>
    </html>
  );
}

오 이제 깜빡이지 않아요!!

Cookie에 테마 정보가 없을 때는 script 태그로 현재 PC의 기본 테마를 가져와 처리하도록 했습니다.

드디어 FOUC 문제를 해결되었습니다 :)

💡 근데 이럴거면 html blocking 사용해서 cookie 사용하지 않고 localStorage로 해도 되는거 아니야??

맞아요 ㅎㅎ localStorage로도 충분히 할 수 있어요. 근데 필자는 모든 내용을 스크립트에서 처리하고 싶지 않았어요. 만약 그런 생각이 드셨다면 직접 구현해보는 것도 좋겠네요!! (절대 귀찮아서 아님)


의견은 언제든 댓글로 남겨주세요. 🙂

참고 자료

profile
미래의 내가 참고하려고 모아가는 중 :)
post-custom-banner

0개의 댓글