돌멩이 하나/셀프 크리틱

다크 모드 - 화면 깜빡임(FOUC) 이슈 해결 및 시스템 설정과 연동

미래에서 온 개발자 2023. 6. 14. 21:06

온라인 셀러 계산기 모바일 화면

온라인 셀러 계산기 사이트: https://seller-calculator.vercel.app/
 
개인 프로젝트로 마진, 판매가, 행사가 환원 등을 구할 수 있는 온라인 셀러 계산기를 뚝딱 만들어 보았다. 요구사항에는 없었지만 지난주 기능 구현 챌린지에서 만들었던 다크 모드 토글 컴포넌트를 가져와서 적용해 보았다. 그 과정에서 생긴 이슈와 해결책을 정리해 보기로 한다. 
 
 

1. useEffect hook

tailwind css를 사용하면 다크 모드를 굉장히 손쉽게 구현할 수 있다. 새로고침 시에도 테마가 유지될 수 있도록 사용자가 선택한 테마를 로컬 스토리지에 저장하기로 했다. 그리고 공식문서에서 알려준 대로 시스템 설정도 체크할 수 있도록 window.matchMedia() api를 사용했다. 
 

    if (
      localStorage.theme === THEME.DARK ||
      (!(LOCAL_STORAGE_KEY.THEME in localStorage) &&
        window.matchMedia("(prefers-color-scheme: dark)").matches)
    ) {
      localStorage.setItem(LOCAL_STORAGE_KEY.THEME, THEME.DARK);
      document.documentElement.classList.add(THEME.DARK);
    } else {
      localStorage.removeItem(LOCAL_STORAGE_KEY.THEME);
      document.documentElement.classList.remove(THEME.DARK);
    }

 
처음에는 단순히 useEffect 안에서 위의 코드를 불러오면 될 거라고 생각했다. 의존성 배열을 비워두면 처음 컴포넌트가 마운트될 때 위의 코드대로 로컬 스토리지에 저장된 theme을 확인하고, 스토리지에 저장된 theme이 없다면 시스템 설정에 따라 라이트 모드/다크 모드 여부를 판단해 설정해 줄 거라고 생각했다. 
 

 
순간적으로 라이트 모드가 먼저 적용되었다가 다크 모드로 바뀌어서 화면이 깜빡거린다. 디폴트가 라이트 모드이기 때문에 최초 렌더링시 라이트 모드로 화면이 만들어지고, useEffect hook이 실행된 이후에 다크 모드로 바뀌는 것이다. 
 
 

2. useLayoutEffect hook

이를 해결하려면 화면이 그려지기 전에 테마를 판별해야 했다. 
 

React Hook Flow Diagram (출처: github.com/donavon/hook-flow)

 
useLayoutEffect는 기본적으로 useEffect hook과 동일하지만 실행 시점에 차이가 있다. 위의 도표에서 보는 것처럼 브라우저가 리페인트를 하기 전에 layoutEffect hook이 실행된다. 
 

import { useLayoutEffect } from "react";
import { LOCAL_STORAGE_KEY, THEME } from "@/util/commonConstants";
import Toggle from "./Toggle";

function ToggleBox() {
  const toggleDarkMode = () => {
    if (localStorage.getItem(LOCAL_STORAGE_KEY.THEME) === THEME.DARK) {
      localStorage.removeItem(LOCAL_STORAGE_KEY.THEME);
      document.documentElement.classList.remove(THEME.DARK);
    } else {
      localStorage.setItem(LOCAL_STORAGE_KEY.THEME, THEME.DARK);
      document.documentElement.classList.add(THEME.DARK);
    }
  };

  useLayoutEffect(() => {
    if (
      localStorage.theme === THEME.DARK ||
      (!(LOCAL_STORAGE_KEY.THEME in localStorage) &&
        window.matchMedia("(prefers-color-scheme: dark)").matches)
    ) {
      localStorage.setItem(LOCAL_STORAGE_KEY.THEME, THEME.DARK);
      document.documentElement.classList.add(THEME.DARK);
    } else {
      localStorage.removeItem(LOCAL_STORAGE_KEY.THEME);
      document.documentElement.classList.remove(THEME.DARK);
    }
  }, []);

  return (
    <div className="mt-4 flex justify-end items-center">
      <span className="mr-2 text-gray-500 dark:text-dark-blue-100">
        다크 모드
      </span>
      <Toggle handleToggle={toggleDarkMode} />
    </div>
  );
}

export default ToggleBox;

화면의 깜빡거림은 멈췄지만 토글 버튼이 라이트 모드 위치에 있다가 왼쪽으로 이동하고 있다. 화면의 색상은 페인트 요소이지만 토글 버튼의 움직임에는 tranform: translateX() 속성이 걸려 있다. translate는 화면의 레이아웃과 관련되어 있기 때문에 리페인트(repaint)가 아닌 리플로우(reflow)를 일으키는 스타일 속성이다. 위의 다이어그램을 다시 잘 살펴보면 useLayoutEffect는 페인트 이전에 호출되기는 하지만, 이 시점에는 이미 리액트가 DOM의 레이아웃을 계산하여 업데이트한 시점이다. 즉, useLayoutEffect는 리플로우와 리페인트 사이에 실행되는 hook이다. 
 

3. inline script

렌더링 이전에 코드를 실행시킬 수 있는 방법을 고민하다가 스크립트 코드를 인라인으로 작성해서 <body> 태그 이전에 위치하게 하면 되지 않을까? 라는 생각을 했다. 
 

import { Html, Head, Main, NextScript } from "next/document";

export default function Document() {
  const setThemeMode = `
      if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
        localStorage.setItem("theme", "dark");
        document.documentElement.classList.add('dark');
      } else {
        localStorage.removeItem("theme");
        document.documentElement.classList.remove('dark');
      }
  `;

  return (
    <Html lang="en">
      <Head>
        <link rel="shortcut icon" href="/favicon.ico" />
        <link rel="manifest" href="/manifest.json" />
        <script dangerouslySetInnerHTML={{ __html: setThemeMode }} />
      </Head>
      <body className="h-screen dark:bg-dark-blue-200 dark:text-dark-blue-100">
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

 
Next.js 프로젝트라서 _document.js 파일을 위와 같이 작성해 주었다. <body> 태그보다 인라인 스크립트가 먼저 실행되기 때문에 다크 모드 상태에서 새로고침을 눌러도 화면의 번쩍거림이나 토글 버튼 위치가 튀는 현상이 모두 잡힌다. 
 

 
검색에 검색을 거듭하다가 이렇게 새로고침 후 개발자가 원하는 스타일이 한 번에 적용되지 않고 화면이 번쩍거리거나 밀리는 현상을 FOUC(Flash of Unstyled Content)라고 부르는 용어가 있다는 걸 알게 되었다.
하지만 FOUC 이슈를 해결하자 또다른 문제점을 발견했다. 개인적으로 mac을 사용하고 있고, 시스템 설정에서 화면 모드를 자동으로 해놔서 일몰 시각에 맞춰 시스템 설정이 다크 모드로 바뀌게 해놓고 있다. 그런데 해가 지고 컴퓨터 화면의 모든 앱들이 다크 모드로 바뀌어가는 중에 온라인 셀러 계산기 창만은 꿋꿋하게 라이트 모드를 지키고 있는 것이 아닌가...! 🤦🏻‍♀️
 
 

번외편: MediaQueryList Listener

지금의 코드로는 렌더링 이전에 시스템 설정 여부를 판단할 뿐 시스템 설정에 시시각각 반응하는 '리스너'가 없으니 당연한 결과였다.
 

 
리스너를 알아보기 전에 먼저 window.matchMedia('(prefers-color-scheme: dark)')가 어떤 역할을 하는지 조금 더 상세히 살펴보자.  window.matchMedia()는 MediaQueryList 객체이다. 이 객체는 문서, 즉 document에 적용된 미디어 쿼리에 대한 정보를 저장하며, 문서 상태에 대한 즉각적인 일치 및 이벤트 기반 일치를 모두 지원한다. 주기적으로 값을 폴링(변화를 지속적으로 계속 확인하고 그에 따라 프로그램을 처리하는 방식)하는 대신에 문서를 관찰하여 미디어 쿼리가 변경되는 시점을 감지할 수 있고, 미디어 쿼리 상태에 따라 프로그래밍적으로 문서를 변경할 수 있다. 위에서 보다시피 matches 속성은 다크 모드일 때 true, 라이트 모드일 때 false인 boolean 값을 가진다. 
 
클릭, 스크롤 등 특정 이벤트가 일어나는지 계속 관찰하고 있는 event listener가 있는 것처럼 window.matchMedia() api에 변동 사항이 있는지 관찰하는 리스너를 달아주면 된다. 이벤트 리스너는 이벤트의 종류와 callback 함수 두 개의 인자를 가지지만, Media Query List의 addListener 메소드는 callback 함수만 있으면 된다. 
 
mdn의 예제를 따라 inline script에 다음과 같이 미디어 쿼리 리스트 리스너를 추가해 주고, 기존 setThemeMode는 하나의 함수로 만들어 실행시켜주었다. 
 

  const setThemeMode = `
    function setTheme() {
      if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
        localStorage.setItem("theme", "dark");
        document.documentElement.classList.add('dark');
      } else {
        localStorage.removeItem("theme");
        document.documentElement.classList.remove('dark');
      }
    }

    function handleThemeChange(e) {
      if (e.matches) {
        localStorage.setItem("theme", "dark");
        document.documentElement.classList.add('dark');
      } else {
        localStorage.removeItem("theme");
        document.documentElement.classList.remove('dark');
      }
    }

    const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
    darkModeQuery.addListener(handleThemeChange);
    setTheme();
  `;

 

📍 셀프 크리틱: 
의도대로 동작하게 만드는 것까지는 성공했지만 inline script를 주입하는 방식이 마음에 들지 않는다. 조금 더 React스럽게, 혹은 Next.js스럽게 해결할 방법은 없을까? 서버에서 html을 만들어오는 pre-rendering 과정에서 일부 javascript 코드까지 주입할 수는 없는 걸까? 

 
 
📚 참고 자료

 

Dark Mode - Tailwind CSS

Using Tailwind CSS to style your site in dark mode.

tailwindcss.com

 

dark모드 하면서 있었던 일

nextJS기반으로 내 블로그 만들기 프로젝트를 하면서 꼭 추가하고 싶었던 기능은 다크모드였다. 이유는 아주 간단히, 내가 대부분의 웹페이지를 사용할때 다크모드를 이용했기 때문이다. 게다가,

velog.io