Please enable JavaScript to view the comments powered by Disqus.

Gatsby 웹사이트에 적용한 다크 모드

몇년 전부터 Windows, macOS, iOS등의 운영체제가 다크 모드를 지원하기 시작했고 그와 더불어 다크 모드를 지원하는 웹사이트도 많아졌다. 다크 모드는 어두운 곳에서의 가독성 향상은 물론 전력 소모도 줄어들기 때문에 무척 유용하다. 개인적으로는 낮에도 다크 모드로 노트북과 휴대폰을 사용하고 있기도 하다. 그래서 직접 제작한 이 블로그에도 다크 모드를 적용하기로 했다.

요구사항

다크 모드를 개발하기 위해 정의한 요구사항은 다음과 같다.

  • 웹사이트는 라이트, 다크 2개의 컬러 테마를 가진다.
  • 웹사이트에 최초 방문시 운영체제의 화면 스타일로 테마를 설정한다.
  • 웹사이트의 테마 설정은 사용자의 브라우저에 저장해서 재방문 시에도 유지한다.
  • 테마 전환 버튼을 제공해서 사용자가 직접 바꿀 수 있도록 한다.

웹사이트에 스타일 테마 적용

styled-components를 사용했다가 실패한 경험

이 블로그 웹사이트(blog.rhostem.com)는 Gatsby로 구현되어 있다. Gatsby는 React에 기반한 프레임워크라서 styled-components를 사용해서 개발을 시작했다. styled-components에는 컴포넌트에 테마 스타일 객체를 전달할 수 있도록 ThemeProvider 컴포넌트를 제공하고 있기도 하다.

const theme = {
  colors: {
    body: '#fefefe'
  }
}

const DefaultLayout = styled.div`
  background: ${({ theme }) => theme.colors.body};
`

function Layout() {
  return (
    <ThemeProvider theme={theme}>
      <DefaultLayout>
        <App></App>
      </DefaultLayout>
    </ThemeProvider>
  )
}

ThemeProvider에 전달한 테마 객체는 styled-component의 템플릿 리터럴 안에서 인터폴레이션을 통해 접근할 수 있다.

처음에는 이 방식으로 접근해서 테마 구현을 끝냈었다. 그런데 개발 서버에서는 문제가 발생하지 않았지만, Gatsby를 빌드한 후에 띄운 서버에서는 문제가 발생한다는 사실을 뒤늦게 알게 되었다. Gatsby는 빌드 결과물에 HTML과 CSS를 모두 포함하고 있기 때문이었다.

Gatsby는 개발 모드에서 페이지에 접근하면 서버 렌더링을 하지 않는다. 일반적인 리액트 앱처럼 모든 처리를 클라이언트 사이드에서 하며 styled-components 같은 css-in-js 라이브러리로 작성한 스타일은 모두 앱이 구동된 후에 추가된다. 하지만 빌드를 한 후 페이지에 접근하면 styled-components로 작성한 스타일이 모두 style 태그에 추가된 상태의 HTML 페이지를 제공한다.

이런 특징이 테마가 분리되기 전에는 문제가 전혀 없었다. 하지만 테마가 분리된 후에는 문제가 생겼다. 왜냐하면 빌드 시점에 설정한 테마가 먼저 제공된 후 브라우저에 저장된 사용자의 테마를 적용하기 때문이다. 예를 들어 기본 테마를 “라이트”로 해서 빌드를 해서 배포를 했는데, 사용자가 “다크” 테마를 선택한 후 새로 고침을 하면 “라이트” 테마가 우선 표시된 후 “다크” 테마로 바뀌는 깜빡임 현상이 발생하게 된다.(테마 컬러 전환에 CSS transition 이펙트를 넣어뒀더니 눈이 아팠다…)

서버 사이드 렌더링 웹사이트라면 쿠키나 DB에 저장된 사용자의 테마를 가져와서 깜빡임이 없도록 할 수도 있겠지만 Gatsby는 완전히 프리 렌더링된 정적인 웹사이트라서 불가능하다. 그렇다고 브라우저 스토리지에서 테마 설정값을 가져오기 전까지 웹사이트를 표시하지 않는다? 그것도 사용자에게 웹사이트 사용을 위해 기본적으로 필요한 네트워크 딜레이에 자바스크립트 파싱 및 실행 딜레이까지 추가하는 행위라서 좋지 않다.

결국 styled-component의 ThemeProvider는 버리고, CSS 커스텀 속성을 사용하기로 했다.

CSS Custom Properties

CSS 커스텀 속성은 CSS에서 변수를 사용할 수 있도록 한다. 변수는 어디에 선언해도 상관없지만, 전역에서 사용할 수 있도록 하려면 :root 가상 선택자에 선언한 후 사용하면 된다.

:root {
  --main-bg-color: brown;
}

body {
	background: var(--main-bg-color); 
}

그리고 테마 설정을 위해서는 커스텀 속성을 아래와 같이 작성할 것이다.

body {
  &.light {
    --text: rgba(46, 46, 46, 0.95);
  }

  &.dark {
    --text: rgba(215, 215, 215, 0.9);
  }  
}

이렇게 하면 body 태그에 어떤 클래스가 붙느냐에 따라 같은 이름을 가진 변수라도 다른 값을 가지게 된다. 다음으로 필요한 작업은 웹사이트 로딩 시점에 테마를 확인해서 body 태그에 클래스를 추가하는 것이다.

시스템 컬러 설정으로 테마 초기화

아까 발생했던 문제는 테마를 변경하는 과정이 HTML을 기반으로 DOM 트리가 구성된 후 100 킬로바이트가 넘는 자바스크립트 파일을 파싱한 후에 천천히(컴퓨터 관점에서는) 이뤄졌기 때문이다. 그렇다면 깜빡임 현상을 없애기 위해서는 테마를 설정하는 시점이 스타일시트를 불러온 후, DOM 트리가 구성되기 전이어야 한다. 그러기 위해서는 body 태그의 첫번째 child element로 script 태그를 추가하고 그 안에 자바스크립트를 작성하면 된다.

Gatsby에 html.js 파일에 우선 실행될 스크립트 추가

Gatsby에서 저것을 가능하게 하려면 gatsby-ssr.js 파일에 onRenderBody 모듈을 작성해도 되고, html.js 파일을 추가해도 된다. html.js 파일은 Gatsby에서 index.html 파일 역할을 한다(Next.js의 _document.js와 유사).

html.js 파일의 구성은 아래와 같다. 다른 내용은 자세히 볼 필요 없고, body 태그 아래에 있는 script 태그의 dangerouslySetInnerHTML에 테마 설정에 필요한 스크립트를 추가할 것이라는 사실만 알면 된다.

import React from 'react'

export default class HTML extends React.Component {
  render() {
    return (
      <html {...this.props.htmlAttributes}>
        <head>
          <meta charSet="utf-8" />
          <meta httpEquiv="x-ua-compatible" content="ie=edge" />
          <meta
            name="viewport"
            content="width=device-width, initial-scale=1, shrink-to-fit=no"
          />
          {this.props.headComponents}
        </head>

				{/* 빌드 시점의 테마 설정을 위해 className에 light를 추가했다. */}
        <body {...this.props.bodyAttributes} className="light"> 
          <script
            dangerouslySetInnerHTML={{
              __html: `
								// 테마 초기화 코드가 들어갈 곳
            `,
            }}
          />
          {this.props.preBodyComponents}
          <div
            key={`body`}
            id="___gatsby"
            dangerouslySetInnerHTML={{ __html: this.props.body }}
          />
          {this.props.postBodyComponents}
        </body>
      </html>
    )
  }
}

테마 초기화 코드

소스 코드는 https://github.com/gaearon/overreacted.io/blob/master/src/html.js 파일을 참고했다.

(function() {
	window.__onThemeChange = function () {} // 컴포넌트에서 정의할 테마 변경 콜백

	var preferredTheme // 초기화에 사용할 테마
	try {
	  preferredTheme = localStorage.getItem('theme') // 로컬 스토리지에 있는 것 사용
	} catch (err) {}

	// 테마 설정 함수
	window.__setPreferredTheme = function (newTheme) {
	  window.__theme = newTheme // 테마는 전역 변수에 저장
	  preferredTheme = newTheme
	  document.body.className = newTheme // 바디에 클래스 추가
	  window.__onThemeChange(newTheme) // 콜백 실행

	  try {
	    localStorage.setItem('theme', newTheme)
	  } catch (err) {}
	}

	// 컬러모드 미디어 쿼리 객체를 가져온다
	var darkQuery = window.matchMedia('(prefers-color-scheme: dark)')

	// 컬러모드 변경 탐지 이벤트 리스너 추가
	darkQuery.addListener(function (e) {
	  window.__setPreferredTheme(e.matches ? 'dark' : 'light')
	})

	// 테마 설정. 저장된 테마가 없으면 시스템 설정을 사용한다.
	window.__setPreferredTheme(
	  preferredTheme || (darkQuery.matches ? 'dark' : 'light')
	)
})();

코드는 IIFE(즉시실행) 함수에 둘러싸여 있어 바로 실행된다. 언더바 두개(__)가 붙은 변수와 함수는 나중에 React 모듈에서 사용하기 위한 목적으로 window 객체에 추가되었다.

window.matchMedia는 주어진 미디어 쿼리를 분석한 결과를 나타내는 MediaQueryList 객체를 반환한다. prefers-color-scheme: dark 쿼리로 생성한 객체의 matches 는 boolean 값으로 현재 컬러 스타일이 다크 모드인지 아닌지를 나타내며, addListener 함수는 웹사이트에서 시스템이 컬러 스타일을 변경하는 이벤트를 탐지할 수 있도록 한다.

여기까지 하면 시스템 컬러 설정을 웹사이트의 테마에 반영할 수 있다. 남은 것은 사용자가 직접 테마를 변경할 수 있도록 하는 토글 버튼이다.

테마 토글 기능

토글 버튼 콜백에서는 html.js에 추가한 __setPreferredTheme 함수를 호출해야 한다. 그리고 시스템 컬러 변경 이벤트를 토글 버튼에도 반영하기 위해 비어있는 __onThemeChange 함수에 토글 버튼의 상태를 변경하는 함수를 재할당해주면 된다.

테마 초기화 커스텀 훅

export const useDarkMode = () => {
  const [theme, setTheme] = useState(null)

  const toggleTheme = useCallback(
    () => {
      const nextTheme =
        theme === themeNames.LIGHT ? themeNames.DARK : themeNames.LIGHT

      setTheme(nextTheme)
      window.__setPreferredTheme(nextTheme)
    },
    [theme]
  )

  useEffect(() => {
		// 클라이언트에서는 window.__theme 값으로 테마를 설정한다
    if (typeof window === 'object') {
      setTheme(window.__theme)
    }

		// 테마 변경 시점에 실행할 로직을 추가한다. 
		// __setPreferredTheme은 변경할 수 없으므로 여기에 React에서 사용할 수 있는 로직을 추가한다.
    window.__onThemeChange = newTheme => {
      setTheme(newTheme)
    }
  }, [])

  return { theme, toggleTheme }
}

나는 개인적으로 데스크탑용, 모바일용 토글 버튼이 하나씩 있어서 커스텀 훅을 구현하고 컨텍스트를 사용해서 상태와 함수를 컴포넌트에 전달했지만, 필요 없다면 토글 버튼에 상태 관리 로직을 직접 구현해도 될 것이다.

const DefaultLayout = ({ children }) => {
  const { theme, toggleTheme } = useDarkMode()

  return (
    <DarkModeContext.Provider
      value={{
        theme,
        toggleTheme,
      }}>
      <Navbar />
      <Page>
        <PageContents>{children}</PageContents>
        <Footer />
      </Page>
    </DarkModeContext.Provider>
  )
}

const DarkmodeToggleButton = () => {
  const { theme, toggleTheme } = useContext(DarkModeContext)
  return (
    theme && (
      <ToggleButton onClick={toggleTheme}>
        <ToggleTrack>
          <div className="moon" />
          <div className="sun" />
          <ToggleThumb themeName={theme} />
        </ToggleTrack>
      </ToggleButton>
    )
  )
}

특히 토글 버튼에서 theme 값이 truthy한 값이어야 렌더링 되도록 조건을 달았는데, 저렇게 하지 않으면 토글 버튼이 처음부터 브라우저에 저장된 테마가 적용되지 않는다. 대신 토글 버튼의 themeName props가 null일 때 표시되는 값에서 저장된 값으로 전환되는 깜빡임 현상이 발생한다. 저 부분은 버튼이 화면에 나타나기까지 딜레이가 조금 발생하더라도 어쩔 수 없다.


이렇게 테마 분리, 시스템 테마 반영 및 탐지, 컬러 테마 변경 UI까지 구현이 끝났다. 참고한 자료들은 다음과 같다.

참고 자료