SSR 환경의 다크모드 깜빡임 현상 해결하기

sxungchxn.dev·2022년 12월 6일
31

Frontend

목록 보기
5/7
post-thumbnail

😱 사건의 발단

NextJSEmotion을 기반으로 진행한 프로젝트에서 다크모드를 도입하여 사이트를 구성해보았다. 그런데 웬걸 새로고침이나 페이지 진입 할 때 카메라 플래시마냥 깜빡임 현상이 발생하고 말았다 😇

이번 포스트에서는 이 깜빡임이 무슨 원인에서 비롯되었는지 분석해보고 어떤 식으로 해결할 수 있는지 공유해볼 것이다.



👀 다크모드 짚어보기

이번 프로젝트에서 구현해본 다크모드는 다음의 기능들을 만족해야한다.

  1. 이미 진입했던 사용자가 로컬스토리지를 통해 저장해두었던 테마값이 있을 경우 이에 따라 렌더링을 진행한다.

  2. 그렇지 않고 디바이스에서 지정한 테마(prefers-color-scheme)값이 존재하는 경우 이에 맞는 화면을 렌더링한다.

  3. 그마저도 아니라면 기본 색상인 다크 테마를 띄운다.

이제 이 요구사항을 구현해보자!

🌚 다크모드 구현하기

다음과 테마별로 사용할 스타일 속성을 지정하였다.

import { Theme } from '@emotion/react';

const lightTheme: Theme = {
  mode: 'light',
  text: 'black',
  background: '#fafafa',
  borderColor: '#eaeaea',
  bodyColor: 'white',
};

const darkTheme: Theme = {
  mode: 'dark',
  text: 'white',
  background: '#111',
  borderColor: '#222',
  bodyColor: 'black',
};

export { lightTheme, darkTheme };

그리고 라이트/다크 모드 여부를 확인할 수 있는 ColorModeContext 를 아래와 같이 구성한다.

import { ReactNode, createContext, useEffect, useRef, useContext, useState } from 'react';
import useMediaQuery from '../hooks/useMediaQuery';

interface ColorModeContextValue {
  colorMode: string | null;
  setColorMode: (value: string) => void;
}

const ColorModeContext = createContext<ColorModeContextValue | null>(null);

const ColorModeProvider = ({ children }: { children: ReactNode }) => {
  const [colorMode, setRawColorMode] = useState('dark'); // 기본 테마는 다크로 간주
  const systemPrefers = useMediaQuery('(prefers-color-scheme: dark)');
  const firstRender = useRef(true);

  const setColorMode = (value: string) => {
    setRawColorMode(value);
    window.localStorage.setItem('color-mode', value);
  };

  useEffect(() => {
    // 첫번째 렌더링 시에만 실행
    if (firstRender.current) {
      // osTheme는 운영체제 지정 테마
      const osTheme = systemPrefers ? 'dark' : 'light';
      // 유저가 선택한 테마(로컬스토리지에 저장된 값을 추출)
      const userTheme = window.localStorage.getItem('color-mode');
      // userTheme를 우선으로 하고 없다면 osTheme로 지정
      const theme = userTheme || osTheme;
      setRawColorMode(theme);
      firstRender.current = false;
    } else {
      // 마운트 이후에는 바뀌는 사용자 선호도에 테마 변화를 대응
      setRawColorMode(systemPrefers ? 'dark' : 'light');
    }
  }, [systemPrefers]);

  return (
    <ColorModeContext.Provider value={{ colorMode, setColorMode }}>
      {children}
    </ColorModeContext.Provider>
  );
};

const useColorModeContext = () => {
  const context = useContext(ColorModeContext);
  if (context === null) {
    throw new Error('useColorModeContext must be used within a ThemeProvider');
  }
  return context;
};

export { ColorModeProvider, useColorModeContext };

그 다음에는 ColorModeContext 로부터 변화하는 colorMode에 따라 알맞은 테마를 주도록 다음과 같이 ThemeProvider를 구성한다

import { ReactNode } from 'react';
import { lightTheme, darkTheme } from '../styles/theme';
import { ThemeProvider as EmotionThemeProvider } from '@emotion/react';
import { useColorModeContext } from './ColorModeContext';

const ThemeProvider = ({ children }: { children: ReactNode }) => {
  const { colorMode } = useColorModeContext();
  return (
    <EmotionThemeProvider theme={colorMode === 'light' ? lightTheme : darkTheme}>
      {children}
    </EmotionThemeProvider>
  );
};

export default ThemeProvider;

마지막으로 _app.tsx를 다음과 같이 구성한다.

import type { AppProps } from 'next/app';
import ThemeProvider from '../components/ThemeProvider';
import { ColorModeProvider } from '../components/ColorModeContext';
import GlobalStyles from '../styles/GlobalStyles';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <ColorModeProvider>
      <ThemeProvider>
        <GlobalStyles />
        <Component {...pageProps} />
      </ThemeProvider>
    </ColorModeProvider>
  );
}

참고로 이렇게 했을때 development 모드에서는 잘 안될 수가 있다.이는 reactStrictMode가 켜져있기 때문이다. 아래와 같이 next.config.js를 수정해주면 된다.

/** @type {import('next').NextConfig} */
const nextConfig = {
  // reactStrictMode: true, // 이 부분을 지워버리자!
  swcMinify: true,
};

module.exports = nextConfig;
💡 reactStrictMode는 development 모드에서 버그를 찾는데 도움을 주는 요소이기 때문에 다크모드 구현이 끝나면 되돌리는 것을 추천드립니다.

관련된 자세한 코드는 여기서 확인할 수 있다.

일차적으로 구현했을때의 모습은 다음과 같다.

버튼을 누르면서 테마를 전환할 수 있고 시스템 선호도를 변경하면 이에 따라 반응하는 것을 확인할 수 있다. 또한 새로고침을 하더라도 시스템 선호도가 아니라 사용자가 지정한 값으로 초기화되는 것을 확인할 수 있다.

🔦 문제와 원인 분석

잘 되는 것 같지만 새로고침을 하게되면 깜빡임 현상이 발생한다. 순간 검은색으로 변하는 현상이 발생하는 것이 보일 것이다.

이러한 문제가 발생하는 것은 초기에 렌더링한 색상과 차후에 구현한 다크모드 기능끼리 충돌이 발생하기 때문이다.

const ColorModeProvider = ({ children }: { children: ReactNode }) => {
  const [colorMode, setRawColorMode] = useState('dark'); // 초기 테마값
  const systemPrefers = useMediaQuery('(prefers-color-scheme: dark)');
  const firstRender = useRef(true);

  ...
  
  useEffect(() => {
    // 마운트 이후 조정되는 테마값
    if (firstRender.current) {
      const osTheme = systemPrefers ? 'dark' : 'light';
      const userTheme = window.localStorage.getItem('color-mode');
      const theme = userTheme || osTheme;
      setRawColorMode(theme);
      firstRender.current = false;
    } else{
      ...
    }
  }, [systemPrefers]);

  return (
    <ColorModeContext.Provider value={{ colorMode, setColorMode }}>
      {children}
    </ColorModeContext.Provider>
  );
};

서버사이드 렌더링이 이뤄질 당시에는 브라우저 API를 참조할 수 없기 때문에 임의로 테마값을 렌더링 할 수 밖에없다. 그러면 기본값인 다크모드의 페이지가 나오게 될것이다. 그러다가 마운트 되고 나서는 로컬스토리지와 시스템선호도값을 파악하게 되고 이때 라이트모드의 페이지로 바꿔줘야한다. 이 때문에 깜빡임이 발생하게 되는 것이다.



🤔 해결책을 고민해보자

1. 서버가 브라우저 없이도 미리 사용자의 테마를 알고 있기?

이런 기능을 구현하기 위해서는 사용자별로 테마를 데이터베이스에 들고 있어야 하고 테마 전환시 API 통신을 해야하는 등 배보다 배꼽이 큰 상황이 발생하게 될 것이다. 또한 서비스에 처음으로 진입하는 사용자의 테마는 알길이 없으므로 이는 해결책으로 가져가기 어려운 방법이다. 따라서 클라이언트 단에서 해결하는 방법이 고안되어야 한다.

2. 클라이언트 단에서 돔요소를 렌더링하기 전에 테마를 먼저 파악한다면?

돔요소들을 띄우기 전에 테마가 무엇인지 파악하여 다크모드인지 라이트 모드인지를 알맞게 설정 한다면 정확한 페이지를 렌더링 할 수 있을 것 같다!



🛼 짚고가야할 사전 지식들

해결책을 설명하기에 앞서 짚고가야할 지식들을 알아보자

렌더링의 프로세스

기본적으로 웹 페이지를 렌더링하는 과정에는 크게 6가지의 과정들이 있다. 여기서 렌더링이란 화면요소를 그리는 과정을 의미한다.

1. HTML 파싱

HTML 파일을 읽어들이면서 마크업 단위로 쪼개는 과정이다. div, img, h1 등 여러가지 태그들을 읽어들이며 의미있는 단위로 만들어낸다. 파싱순서는 위에서 아래로 그리고 우에서 좌측으로 진행된다. 이때 유념해야 될 것은 script 태그 같은 경우 따로 속성을 지정하지 않으면(defer 등) script 해석이 끝날때까지 HTML 파싱이 블로킹 된다는 점이다.

2. CSS 파싱

CSS 파일을 읽어들이는 과정이다. HTML과 비슷하게 읽어들이는 과정이다.

3. DOM, CSSOM 트리 생성

앞서 파싱된 HTML과 CSS를 바탕으로 DOM 트리, CSSOM 트리를 만들어낸다. 어떤 요소들과 부모 자식 관계를 가져내는지를 트리 구조로 나타내는 과정이다.

4. 렌더 트리 생성

DOM, CSSOM 트리가 만들어지고나서 각각 특정 DOM이 어느 CSSOM과 결합되는지를 나타내는 과정이다. 이때까지도 실제 화면이 그려지는게 아니라 객체의 형태로만 나타난다.

5. 레이아웃

형성된 렌터 트리 내 요소들이 어느 위치에 그려지는지를 연산하는 과정이다. 예를 들어 div 는 x좌표가 20이고 y좌표가 50 이어야 겠다라는 것을 계산하는 과정이다. 이 레이아웃 과정은 다른 요소들에게도 영향을 주는 과정이라 비용이 굉장히 비싸다.

6. 페인트

레이아웃으로 구성된 요소들을 실제 화면에 그려내는 과정이다. 앞써 레이아웃이 비싼 과정이었다면 페인트는 분석된 값들을 화면에 그려내기만 하면 되기 때문에 비용이 비싼 과정은 아니다.

여기서 집중해서봐야될 과정은 1, 2, 3번이다. DOM 트리와 CSSOM 트리가 생성되면서 어떤 요소들이 어떤 스타일로 띄워질지 결정되는 과정들이기 때문이다.

그렇다면 우리의 문제와 결합시켜서 생각해보았을때 이 DOM트리가 생성되기 직전에 어떤 테마로 화면을 그려낼지 결정을 하면 되지 않을까?

HTML Blocking

앞서 렌더링의 1번 과정을 설명할때 script 태그가 해석되는 동안에는 HTML 파싱이 중단된다고 하였다. 이러한 현상을 HTML Blocking 이라고 한다.

<body>
  <script>
    alert('다크모드 개선하자!');
  </script>
  <div>
    ...
  </div>
</body>

위 코드의 실행결과인 위의 사진을 보면 확인해볼 수 있듯이 script 태그 내부의 alert 함수 호출로인하여 h1 태그가 렌더링 되지 않고 막혀버리는 모습이다. 이 원리를 활용해본다면 DOM트리가 생성되기 직전에 어떤 테마로 화면을 그려낼지 결정 하는 과정을 잘 처리해볼 수 있을 것 같다.

<<html>
  <head>
    <title>Dark Mode</title>
  </head>
  <body>
    <script>
      /*
        - 로컬 스토리지를 확인해본다.
        - prefers-color-scheme라는 미디어 쿼리 속성을 확인해본다.
        - 어떤 테마로 띄울지를 결정한다.
      /*
    </script>
    <div>
     	...
    </div>
  </body>
</html>

위와 같이 HTML을 구성해본다면 어떤 테마로 띄워볼지 결정해보는 과정을 돔요소를 띄우기 전에 할 수 있을 것이다.

CSS 변수

기존에 Emotion으로 라이트/다크모드에 맞는 테마를 렌더링하는 로직은 아래와 같다.

const lightTheme: Theme = {
  mode: 'light',
  text: 'black',
  background: '#fafafa',
  borderColor: '#eaeaea',
  bodyColor: 'white',
};

const darkTheme: Theme = {
  mode: 'dark',
  text: 'white',
  background: '#111',
  borderColor: '#222',
  bodyColor: 'black',
};

const ThemeProvider = ({ children }: { children: ReactNode }) => {
  const { colorMode } = useColorModeContext();
  return (
    <EmotionThemeProvider theme={colorMode === 'light' ? lightTheme : darkTheme}>
      {children}
    </EmotionThemeProvider>
  );
};

그리고 이런 테마값들을 이용해 컴포넌트들을 스타일링하면 다음과 같이 할 수 있다.

const Button = styled.button`
  background: ${({ theme }) => theme.background};
  color: ${({ theme }) => theme.text};
  padding: 1rem;
  border-radius: 4px;
`;

이 로직이 문제가 되는 점은 바로 useColorModeContext 에 의존적이라는 것이다. 즉, 마운트가 되면서 바뀔 수 있는 값 colorMode 에 의해 스타일 속성들이 좌지우지 되버리고 이로 인해 깜빡임 현상이 발생하게 된다.

그러므로 돔요소들이 파싱되기 전에 스타일링을 할 수 있도록 해야한다. 한편, 그 CSS 값은 테마가 바뀜에 따라 동일하게 변화할 수 있어야한다. 이때 사용해 볼 수 있는 것이 css 변수이다! CSS 변수는 선언해놓으면 body 태그보다 상단에 위치하게 되며, 그 값을 바꾸면 바로 렌더링에 반영된다.

CSS가 아니라 어색할 수는 있지만 Emotion과 같은 CSS-in-JS 방식에서도 CSS 변수를 손쉽게 사용할 수 있다.

const Button = styled.button`
  background: var(--background-color);
  color: var(--text-color);
  padding: 1rem;
  border-radius: 4px;
`;

따라서 우리가 적용해볼 수 있는 해결책은

1. CSS 변수를 이용해 테마별로 다른 속성들을 스타일링한다.

2. 돔요소가 렌더링 되기 전에 테마를 파악하고 그 테마에 맞는 CSS 값을 설정할 수 있도록 스크립트를 적절히 삽입한다.

로 종합해 볼 수 있다.



🤛 해결해보기!

스크립트 삽입

위에서 살펴봤던 사전 지식들을 바탕으로 깜빡임 현상을 해결할 차례가 되었다. 우선 스크립트를 삽입해보는 방법에 대해서 알아보자.

Next.js에서 모든 돔요소보다 상단에 스크립트가 존재하기 위해서는 _document.tsx 라는 파일을 만들어주어야 한다. 공식문서에서는 다음과 같이 만들어야한다는 가이드라인을 제공해주고 있다.

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

export default function Document() {
  return (
    <Html>
      <Head />
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  )
}

여기서 _documents.tsx 는 next.js 내 모든 페이지에서 전역적으로 사용되는 HTML 역할이라고 생각하면 된다. 보통은 lang 속성을 지정하는 용도로 사용을 하곤 하는데 우리는 스타일 속성을 지정할 수 있는 스크립트를 넣어줄 것이다.

const ScriptTag = () => {
  const codeToRunOnClient = `(function() {
  alert("다크모드 개선하자!");
})()`;

  return <script dangerouslySetInnerHTML={{ __html: codeToRunOnClient }} />;
};

export default function Document() {
  return (
    <Html>
      <Head />
      <body>
        <ScriptTag />
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

형태는 위와 같으며 클라이언트 단에서 실행될 코드를 문자열화 한뒤 이를 script 태그의 dangerouslySetInnerHTML 속성에 위치시켜준다. 이렇게해두면 다음과 같이 돔 요소가 렌더링 되기 전에 alert가 먼저 실행되는 것을 볼 수 있다.

참고로 리액트 내부적으로 스크립트를 삽입하여 사용할 때는 다음과 같이 dangerouslySetInnerHTML 속성을 이용해주는 것이 좋은데 이는 리액트에서 XSS 공격을 방지하기 위한 정책을 우회하기 위함이다.

필요한 스크립트 작성하기

스크립트 내부에서 실행되어야할 내용은 다음과 같다.

  • 로컬스토리지와 미디어 쿼리의 prefers-color-scheme 속성을 확인해 렌더링할 컬러모드를 파악한다.

  • 현재 어떤 컬러모드인지를 style 프로퍼티의 --initial-color-code 라는 이름으로 값을 넣는다.

  • 컬러모드 별 필요한 스타일링 속성에 따라 CSS 변수값을 설정한다

이러한 요구사항을 만족할 수 있게 스크립트와 관련 함수를 작성해보자!

const COLOR_MODE_KEY = 'color-mode';
const INITIAL_COLOR_MODE_CSS_PROP = '--initial-color-mode';

function setColorsByTheme() {
  const modeProperties = '[modeProperties]';
  const colorModeKey = '[colorModeKey]';
  const colorModeCssProp = '[colorModeCssProp]';

  // 사용자 선호도 파악
  const mql = window.matchMedia('(prefers-color-scheme: dark)');
  const prefersDarkFromMq = mql.matches;
  
  // 로컬 스토리지에 저장된 테마값
  const persistedPreference = localStorage.getItem(colorModeKey);

  let colorMode = 'dark'; // 컬러모드 기본값은 다크

  const hasUsedToggle = typeof persistedPreference === 'string'; // 로컬스토리지에 저장된 테마값이 있는지 여부를 저장

  if (hasUsedToggle) {
    colorMode = persistedPreference; // 저장했으면 로컬스토리지값 대로 컬러모드 지정
  } else {
    colorMode = prefersDarkFromMq ? 'dark' : 'light'; // 아니라면 선호도에 따라 컬러모드 지정
  }

  const root = document.documentElement;

  // 스타일 태그 속성에 현재 컬러모드 값을 기록
  root.style.setProperty(colorModeCssProp, colorMode);

  // theme 속성값을 기반으로 css 변수를 만들어내기
  // 예를 들어 
  //  "card-background": {
  //  		light: themeColors.primary.light,
  //  		dark: themeColors.secondary.dark,
  //   },
  // 라는 테마값은 var(--card-background)로 변화함
  Object.entries(modeProperties).forEach(([name, colorByTheme]) => {
    const cssVarName = `--${name}`;
    // @ts-ignore
    root.style.setProperty(cssVarName, colorByTheme[colorMode]);
  });
}

이제 이 함수를 문자열화 하여 스크립트에 삽입하자!


const ScriptTag = () => {
  const stringifyFn = String(setColorsByTheme)
    // eslint-disable-next-line quotes
    .replace('"[MODEPROPERTIES]"', JSON.stringify(themeProperties)) // JSON은 문자열로 변환시 쌍따옴표가 생겨서 추가적으로 쌍따옴표를 붙여서 처리해야 함.
    .replace('[COLORMODEKEY]', COLOR_MODE_KEY) 
    .replace('[COLORMODECSSPROP]', INITIAL_COLOR_MODE_CSS_PROP);

  const fnToRunOnClient = `(${stringifyFn})()`;

  return <script dangerouslySetInnerHTML={{ __html: fnToRunOnClient }} />;
};

export default function Document() {
  return (
    <Html>
      <Head />
      <body>
        <ScriptTag />
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

이 과정들은 다소 생소할 수 있다. 여기서 한가지 의문이 들 수 있다.

? 왜 함수와 변수를 문자열로 변환하여 저장하는 것인가?

이 스크립트 태그는 번들링된 자바스크립트를 불러오기 전에 실행이 된다. 이 때문에 번들링 된 값을 그대로 표시되면 원하는대로 실행이 될 수 없다. 가령,

import { themeProperties } from '../styles/theme';

export function setColorsByTheme() {
  const modeProperties = themeProperties;

  ...
  
  // generating css variables based on modeProperties
  Object.entries(modeProperties).forEach(([name, colorByTheme]) => {
    const cssVarName = `--${name}`;
    // @ts-ignore
    root.style.setProperty(cssVarName, colorByTheme[colorMode]);
  });
}

이렇게 작성한다면 themeProperties 라는 변수가 번들링된 자바스크립트를 가져와야 되고 이렇게 할 경우 스크립트가 제대로 실행될 수 없는 것이다. 이러한 이유로 함수 뿐만 아니라 내부 변수들도 문자열화한것은 replace 시키는 것이다. 이렇게 문자열로 변환하여 작성하게 되면 HTML에 다음과 같이 삽입된다.

이렇게 삽입이 되면 어떤 번들링 로드 없이도 바로 실행가능한 스크립트가 완성된다!

테마값 변경하기

테마에서 해야될 것이 있다. setColorsByTheme 함수 마지막 부분에서는 modeProperties라는 오브젝트를 변환해 알맞은 css 변수로 변환해주고 있다.

예를 들어

"card-background": {
    light: themeColors.primary.light,
    dark: themeColors.secondary.dark,
}

라는 테마객체를 var(--card-background)로 변화시키는 방식이다. 이를 위해서 theme 객체에 다음과 같이 값을 추가해준다.

// theme.tsx

const themeProperties = {
  'mode-color': {
    light: lightTheme.mode,
    dark: darkTheme.mode,
  },
  'text-color': {
    light: lightTheme.text,
    dark: darkTheme.text,
  },
  'background-color': {
    light: lightTheme.background,
    dark: darkTheme.background,
  },
  'border-color': {
    light: lightTheme.borderColor,
    dark: darkTheme.borderColor,
  },
  'body-color': {
    light: lightTheme.bodyColor,
    dark: darkTheme.bodyColor,
  },
};

앞으로 다크모드와 라이트모드에 따라 변경되는 스타일 값이 있다면 위와 같은 형식으로 테마 속성을 작성하면 된다.

CSS 변수로 스타일 변경하기

추가적으로 기존의 스타일링 코드를 emotion theme 대신 css 변수로 변환하기만 하면 된다. 가령

const Button = styled.button`
  background: ${({ theme }) => theme.background};
  color: ${({ theme }) => theme.text};
  padding: 1rem;
  border-radius: 4px;
`;

다음과 같이 작성된 코드는 아래로 바꾸면 된다.

const Button = styled.button`
  background: var(--background-color);
  color: var(--text-color);
  padding: 1rem;
  border-radius: 4px;
`;

colorMode 변경함수 기능 추가

마지막으로 버튼을 누르거나 시스템 선호도 변경시 테마 변경 대응하는 부분에서 css 변수도 알맞게 변경해주도록 하는 부분을 추가해주면 된다.

const ColorModeProvider = ({ children }: { children: ReactNode }) => {
  const [colorMode, setRawColorMode] = useState<string | undefined>(undefined);
  const systemPrefers = useMediaQuery('(prefers-color-scheme: dark)');
  const firstRender = useRef(true);

  // 컬러모드 설정 함수
  const setColorMode = useCallback((value: 'light' | 'dark') => {
    const root = window.document.documentElement;
    // 바뀐 값에 대응해 css 변수들도 교체
    Object.entries(themeProperties).forEach(([name, colorByTheme]) => {
      const cssVarName = `--${name}`;
      root.style.setProperty(cssVarName, colorByTheme[value]);
    });
	
    setRawColorMode(value);
    window.localStorage.setItem('color-mode', value);
  }, []);

  useEffect(() => {
    if (firstRender.current) {
      const osTheme = systemPrefers ? 'dark' : 'light';
      const userTheme = window.localStorage.getItem('color-mode');
      const theme = userTheme || osTheme;
      setRawColorMode(theme);
      firstRender.current = false;
    } else {
      setColorMode(systemPrefers ? 'dark' : 'light');
    }
  }, [systemPrefers]);

  return (
    <ColorModeContext.Provider value={{ colorMode, setColorMode }}>
      {children}
    </ColorModeContext.Provider>
  );
};



✨ 결과 확인하기!

기존의 기능은 살리면서도 새로고침 시 더이상 깜빡임이 발생하지 않는 것을 확인할 수 있다!

완성된 코드는 이 레포지토리에서 확인해볼 수 있다.



⛳️ 출처

profile
🏠 버튼을 누르면 더 많은 글들을 보실 수 있습니다

2개의 댓글

comment-user-thumbnail
2022년 12월 25일

내용 꼼꼼하고 정말 좋네요! 감사합니다🙇🏻‍♂️

1개의 답글