ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Next.js] Dark Mode 구현하기
    Front-End(Web)/React - 프레임워크(React, Next) 2022. 9. 12. 19:49
    반응형

    전 회사 사람들과 진행하는 사이드 프로젝트에서, 테마(theme.ts)를 세팅하다가 다크모드 적용여부를 논의하였다.

    (일반적으론, 색상 등으로 설정된 변수명에 값을 넣어주겠지만, 다크모드를 고려하면 방법이 조금 달라진다!)

     

    적용하기로 결정이 되었으며, 이전 프로젝트에서 느꼈듯이 다크테마를 적용하기 위해선 스타일링이 조금 더 복잡해진다.

    배경, 텍스트 등에 색상을 그대로 넣는게 아니라, 변수값에 이 색상들을 저장하고 이 변수들을 기반으로 마크업을 적용해야 한다.

     

    다크테마 색상을 어떻게 설정하는지, 각종 설정들과 연계하여 다크테마를 제공하는 것, Next.js SSR에서의 적용

    다크모드를 적용하면서 내가 고민했던 이슈들과 그 해결법들을 정리해보고 공유해보고자 한다!

     


     

    🎨 Dark Mode 준비

    다크모드는 애플, 구글, 인스타그램 등 세계적인 브랜드들이 애용하기 시작하면서, FE개발에선 UI/UX를 위한 필수기능이 되었다.

    OLED 디스플레이의 배터리 절약, 버닝의 위험성 감소, 그리고 밤이나 어두운 환경에서 눈의 피로도를 줄일 수 있는 장점들이 있다.

     

    이러한 다크모드를 구현하기 위해서는 서론해서 말했듯 몇 가지 고려와 설정이 필요하다.

    1. 테마 및 스타일 세팅 : 색상값을 변수에 저장. 이는 라이트/다크 모드 각각 다른 색을 가지며, 변수명도 색상이 아닌 용도 등으로 설정.
    2. 테마 상태관리 : 라이트/다크 모드를 어떤 형태로 관리할지를 설정. (이는 기술스텍, 디렉토리 구조, 초기값 세팅 등과도 연관)
    3. 테마 유지기능 : 다크모드를 기설정한 유저는 이를 기본값으로 제공, 브라우저 설정과도 연결해줘야 한다.
    4. 테마 SSR 적용 : Next.js는 기본적으로 SSR 환경이며, 최초에 다크테마로 인한 깜빡임을 최소화하는 고민이 필요.

     

    * 초기세팅

    Next.jstypescript, styled-components를 기반으로 작업되며, 우선 Context API(전역상태)를 사용하지 않은 형태로 구현한다.

    src
    ├── components
    │   └── provider
    │       └── LayoutProvider.tsx ─────── Styled-Components, 색상관련 설정
    │
    ├── hooks
    │   └── useTheme.ts ────────────────── 테마상태 및 메서드 제공
    │
    ├── style 
    │   ├── global.ts ──────────────────── 색상변수 및 전역 스타일 
    │   └── theme.ts
    │
    └── pages
        ├── _app.tsx
        ├── _document.tsx ──────────────── SSR 시 초기값 설정추가
        └── index.tsx

     

     

     

    🎨 Dark Mode 구현하기

     

    1. 테마 세팅, 전역 스타일 적용

    먼저, global.ts에서 styled-components의 createGlobalStyle 메서드로 전역 스타일을 설정한다.

    여기서, dataset에 따른 색상변수를 설정하며, body에 기본 스타일을 적용할수도 있다.

    // /styles/global.ts
    import { createGlobalStyle } from 'styled-components';
    
    const GlobalStyle = createGlobalStyle`
     
      body {
        color: var(--text-main);
        background-color: var(--bg-main);
        transition: background 0.2s ease-in, color 0.2s ease-in;
      }
    
      body[data-theme="light"] {
        // major(theme)
        --major-main: #BA1628;
        --major-second: #8E041A;
    
        // text(common)
        --text-title: #111111;
        --text-subTitle: #1D1D1D;
        --text-main: #1E1E1E;
        --text-second: #575757;
        --text-third: #989999;
        --text-fourth: #A8A5A3;
        // text(individual)
        --text-input: #333333;
        --text-rating: #A26F01;
        --text-label: #DBA969;
        --text-label2: #d5A184;
    
        // background(common)
        --bg-main: #FFFFFF;
        --bg-second: #FAFAFA;
        --bg-third: #F7F3F0;
        // background(individual)
        --bg-progressBar: #E9E9E9;
        --bg-button: #02A78B;
        --bg-rating: #F9F7D6;
        --bg-ratingStar: #EFA000;
        --bg-label: #333333;
    
        // border(common)
        --border-main: #DEDEDE solid 1px;
        --border-second: #E4E4E4 solid 1px;
        --border-third: #EAE0DA solid 1px;
      }
    
      body[data-theme="dark"] {
        --text-title: #FFFFFF;
        --text-subTitle: #FFFFFF;
        --text-main: #FFFFFF;
        --bg-main: #1C1C1F;
        --border-main: #DEDEDE solid 1px;
      }
    `;
    
    export default GlobalStyle;

    이처럼, body의 dataset(theme)이 light, dark인 경우 각각 변수에 대한 스타일 팔레트를 설정해준다.

    적용할 색상이 변수에 대응되고 여기에 할당되는 색상값은 dataset에 따라 전환되므로, 변수명은 색상보다는 스타일하는 요소나 기능 등에 초점을 맞춘다.

    * 변수명은 라틴형 서수(primary, secondary..)로 많이 적용하나, 나는 짧은 변수명을 위해 기본 서수로 적용함

     

    상단의 body 마크업에서 볼 수 있듯, var([변수명])를 통해 아래에서 설정한 변수값을 적용할 수 있다.

    transition 효과까지 주어서 라이트/다크 모드 전환 시 간단한 애니메이션을 추가해준 모습이다.

     

     

    2. useTheme() 커스텀 훅으로 관리

    // /hooks/useTheme.ts
    import { useState, useEffect, useMemo, useCallback } from "react";
    
    type ThemeKey = 'light' | 'dark';
    
    type ReturnType = {
      theme: ThemeKey;
      isDarkMode: boolean;
      setTheme: (theme: ThemeKey) => void;
      toggleTheme: () => void;
    };
    
    const useTheme = (): ReturnType => {
      const [theme, setTheme] = useState<ThemeKey>('light');
      const isDarkMode = useMemo(() => theme === 'dark', [theme]);
    
      const initTheme = useCallback(() => {
        const preferDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
        const initalTheme = (localStorage?.getItem('theme') || (preferDarkMode ? 'dark' : 'light')) as ThemeKey;
        setTheme(initalTheme);
      }, []);
    
      useEffect(() => {
        initTheme();
      }, []);
    
      useEffect(() => {
        localStorage.setItem("theme", theme);
        document.body.dataset.theme = theme;
      }, [theme]);
    
      const toggleTheme = useCallback(() => {
        setTheme((prev) => (prev === "light" ? "dark" : "light"));
      }, []);
    
      return { theme, isDarkMode, setTheme, toggleTheme };
    };
    
    export default useTheme;

    useTheme() 이라는 커스텀 훅을 만들어서 다크모드에 관한 상태, 설정 등을 제어하게끔 설정했다.

    기본적으로, useState()를 통해 'light', 'dark' 두 가지 테마모드를 전환한다.

     

    다음으로 두 가지 useEffect() 구문들이 나오는데

    이는 각각 1) 스토리지, 브라우저 설정에 따른 초기 테마세팅, 2) 테마 변경시 dataset, 스토리지 반영 이다.

     

    1) 스토리지, 브라우저 설정에 따른 초기 테마세팅

    렌더링 시 initTheme() 함수를 한 번 실행하게 된다.

    이 함수는, 먼저 브라우저의 다크모드 설정여부를 확인하여 preferDarkMode 변수에 저장한다.

    다음으로, 로컬 스토리지의 테마 저장값을 확인한 뒤 이 값이 있다면 저장값을, 없다면 다크모드 설정 시 'dark'를, 모두 해당되지 않거나 설정값이 없는 경우는 'light'를 default 값으로 설정해준다.

     

    2) 테마 변경시 dataset, 스토리지 반영

    theme 상태값이 변경될 때마다 로컬 스토리지 저장과 dataset(theme) 변경을 처리해준다.

    로컬 스토리지에 최신값을 저장할 뿐 아니라, dataset을 변경하여 글로벌 스타일이 전환되게끔 해주는 것이다.

     

    로컬 스토리지 저장값으로 초기화, dataset에 설정하는 방식들이 일종의 전역상태처럼 동작하고 있다.

    * 하지만, 여러 군데에서 더 확실한 테마모드 공유 및 전환이 요구된다면 Context API나 Recoil 전역상태로 구현되는게 좋긴할듯

     

     

    3. 테마 토글버튼 추가

    // /components/provider/LayoutProvider.tsx
    import React from 'react';
    import styled from 'styled-components';
    import GlobalStyle from '@/styles/global';
    import { ThemeProvider } from 'styled-components';
    import { theme } from '@/styles/theme';
    import useTheme from '@/hooks/useTheme';
    import Header from '@/components/common/Header';
    
    interface Props {
      children: React.ReactNode;
    }
    
    const LayoutProvider = ({ children }: Props) => {
      const { toggleTheme } = useTheme();
    
      return (
        <ThemeProvider theme={theme}>
          <GlobalStyle />
          <Header />
          <MainContainer>{children}</MainContainer>
          <ThemeButton onClick={() => toggleTheme()}>{isDarkMode ? '라이트' : '다크'}모드</ThemeButton>
        </ThemeProvider>
      );
    };
    
    // ...
    const ThemeButton = styled.button`
      position: fixed;
      left: 20px;
      bottom: 20px;
      padding: 5px 10px;
      color: var(--bg-main);
      background-color: var(--text-main);
      border: 1px solid black;
      border-radius: 5px;
    `;
    
    export default LayoutProvider;

    Styled-Components 공통 스타일 설정을 <LayoutProvider>라고 하는 컨테이너 컴포넌트로 별도로 분리했다.

    여기에 useTheme() 커스텀 훅을 활용하여 다크모드 토글버튼을 추가하였다.

     

    다크모드를 리서치해봤다면, Styled-Components 테마설정에 많이 쓰이는 theme.ts에서 lightTheme, darkTheme 2가지 팔레트를 만든 뒤 이를 <ThemeProvider>의 theme Props에서 토글링하는 방법도 보았을 것이다.

    theme 변수를 사용하는가 vs global의 css 변수를 사용하는가 의 차이겠지만, 이후에 다룰 SSR에서 미리 적용하기 위해 css 변수를 사용하게 된 것이다.

     

     

    4. 초기 테마세팅 SSR 추가

    위 3번까지만 적용해도 충분히 다크모드가 구현된다. 다만, 초기 테마세팅이 렌더링 직후 적용되면서 깜빡이는 문제가 발생한다.

    테마에 대한 dataset을 useEffect()가 아닌 렌더링 이전에 적용할 수 있는 방법에 대해서 찾아보았다.

     

    // /pages/_document.tsx
    import Document, { Html, Head, Main, NextScript, DocumentContext } from "next/document";
    
    const themeInitializerScript = `
          (function () {
            document.body.dataset.theme = window.localStorage.getItem("theme") || (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? "dark" : "light");
          })();
      `;
    
    export default class MyDocument extends Document {
      // ...
    
      render() {
        return (
          <Html>
            <Head />
            <body>
              <script dangerouslySetInnerHTML={{ __html: themeInitializerScript }} />
              <Main />
              <NextScript />
            </body>
          </Html>
        );
      }
    }

    이처럼, _document.tsx 컴포넌트<script>를 실제 컨텐츠(<Main>, <NextScript>) 전에 설정해주면 된다.

    _document.tsx는 서버에서만 처리가 이루어지지만,

    <body>가 그려지기 전에 <script>가 먼저 실행되는 순서이므로 dataset이 설정된 다음 컨텐츠가 렌더링된다. 

     

    * 예시

    <body>
      <script>
        alert("No UI for you!");
      </script>
      <h1>Page Title</h1>
    </body>

     

    또한, IIFE(즉시 실행 함수표현)을 사용했는데, 전역 스코프에 불필요한 변수를 적용하지 않기 위함이다.

     

    dangerouslySetInnerHtml은 React에서 innerHTML을 구현하는 문법이다.

    XSS(사이트 간 스크립트 공격) 보안성이 높아지며, Next에선 <root> 컨텐츠가 생성되기 전에 실행할 <script> 설정을 위해 활용한다.

     

    * _app.tsx 에서 안되는 이유

    _app 페이지는 웹서버로 요청이 들어왔을 때 가장 먼저 실행되는 컴포넌트이다.

    전역상태나 글로벌 CSS 등 공통 레이아웃의 적용에 많이 사용되지만,

    서버에서 먼저 처리가 이루어지므로 window, document 같은 객체를 읽을 수 없기 때문이다. (undefined)

     


     

    구현이 마무리된 모습이다! 가볍게 적용해볼까 했는데, 설정저장에 SSR까지 고려하다보니 스코프가 큰 작업이 되어버렸다 🤣🤣

     

    이전 회사에서도, 프로젝트에 다크모드를 적용하기 위해 설계를 했던 적이 있고,

    라이브러리 변수(Less, Styled-Components)보다 CSS 변수를 적용하는 것이 dataset 적용이나 Next.js와 같은 SSR 환경에서 구현되기 좀 더 유리하다는 것을 느꼈다. (특히, CSS-in-JS 라이브러리는 클라이언트 단의 JS로 입혀질 것이므로)

     

    다크모드를 구현한 경험뿐만 아니라, 이를 위해 어떻게 설계하는것이 가장 적합한지를 리서치해본 좋은 경험이 되었다!

     

     

    📎 출처

    - [다크모드 참고] mirayng 님의 블로그 : https://miryang.dev/blog/nextjs-light-dark-mode  

    - [다크모드 참고] veerasundar's blog : https://veerasundar.com/blog/how-to-add-dark-mode-to-your-blog/  

    - [다크모드 참고] sreetamdas's blog : https://sreetamdas.com/blog/the-perfect-dark-mode 

     

    - [다크모드 SSR 개선방법] coccon1787 님의 블로그 : https://cocoon1787.tistory.com/855  

    - [_app & _document] merrily-code 님의 블로그 : https://merrily-code.tistory.com/154

    - [dangerouslySetInnerHtml] betterprogramming post : https://betterprogramming.pub/what-is-dangerouslysetinnerhtml-6d6a98cbc187

     

     

    반응형
Designed by Tistory.