[Next.js] 다크모드 적용하기, SSR에서 깜빡이는 문제 해결

[Next.js] 다크모드 적용하기, SSR에서 깜빡이는 문제 해결

·

4 min read

YEONSUI에 다크모드를 추가하면서 라이북러리에도 적용해보려 한다.

우선 YEONSUI에서 다크모드를 어떻게 구현했는지 살펴보자.

🟡 scss로 테마 색상 구현하기

  1. 팔레트 변수에 저장하기

     // ./constants/_colors.scss
    
     // Daybreak Blue
     $Daybreak1L: #e6f7ff;
     $Daybreak2L: #bae7ff;
     $Daybreak3L: #91d5ff;
     ...
    
     $Daybreak1D: #111d2c;
     $Daybreak2D: #112a45;
     $Daybreak3D: #15395b;
     ...
    
     // Red, Volcano, Sunset, Cyan 등 동일하게 선언하기
    

    1차로 색상 코드를 변수에 저장한다.

  2. Light/Dark별로 색상 지정

     // ./constants/_themes.scss
     $light-colors: (
       'Daybreak1': $Daybreak1L,
       'Daybreak2': $Daybreak2L,
       'Daybreak3': $Daybreak3L,
       ...
     );
     // Red, Volcano, Sunset, Cyan 등 동일하게 선언하기
    
     $dark-colors: (
       'Daybreak1': $Daybreak1D,
       'Daybreak2': $Daybreak2D,
       'Daybreak3': $Daybreak3D,
       ...
     );
     // Red, Volcano, Sunset, Cyan 등 동일하게 선언하기
    

    라이트와 다크모드에 같은 선상에 놓일 색상을 같은 변수명에 선언하여 배열로 저장한다.

  3. root 가상 클래스로 색상 변수 추가하기

     // ./base/_themes.scss
     html:root {
       .theme-light {
         @each $name, $color in $light-colors {
           @include hexRGB($name, $color);
         }
    
         background-color: var(--Gray1);
    
         --Font-Color-Title: rgba(var(--Gray13RGB), 0.85);
         --Font-Color-Primary: rgba(var(--Gray13RGB), 0.65);
         --Font-Color-Secondary: rgba(var(--Gray13RGB), 0.45);
         ...
    
         &.daybreak {
           --Primary-Color-1: var(--Daybreak1);
           --Primary-Color-2: var(--Daybreak2);
           --Primary-Color-3: var(--Daybreak3);
           ...
         }
         &.magenta {
           --Primary-Color-1: var(--Magenta1);
           --Primary-Color-2: var(--Magenta2);
           --Primary-Color-3: var(--Magenta3);
           ...
         }
         // Red, Volcano, Sunset, Cyan 등 동일하게 선언하기
       }
    
       .theme-dark {
         @each $name, $color in $dark-colors {
           @include hexRGB($name, $color);
         }
    
         background-color: var(--Gray2);
    
         --Font-Color-Title: rgba(var(--Gray13RGB), 0.85);
         --Font-Color-Primary: rgba(var(--Gray13RGB), 0.65);
         --Font-Color-Secondary: rgba(var(--Gray13RGB), 0.45);
         ...
    
         &.daybreak {
           --Primary-Color-1: var(--Daybreak1);
           --Primary-Color-2: var(--Daybreak2);
           --Primary-Color-3: var(--Daybreak3);
           ...
         }
         &.magenta {
           --Primary-Color-1: var(--Magenta1);
           --Primary-Color-2: var(--Magenta2);
           --Primary-Color-3: var(--Magenta3);
           ...
         }
         // Red, Volcano, Sunset, Cyan 등 동일하게 선언하기
       }
    

    body 에 클래스명을 추가해서 테마를 설정하도록 .theme-light, .theme-dark 클래스를 추가한다.
    $light-colors, $dark-colors 배열을 맵핑하여 hex와 rgb로 CSS변수를 선언한다. (hexRGB mixin은 바로 아래 코드 참고!)
    CSS변수는 var() 형태로 사용한다. 색상 테마에 맞게 --Primary-Color-n 변수에 다시 색상 변수를 저장한다.

     // ./base/_mixins.scss
     @function hexToRGB($hex) {
       @return red($hex), green($hex), blue($hex);
     }
    
     @mixin hexRGB($name, $hex) {
       --#{$name}: #{$hex};
       --#{$name}RGB: #{hexToRGB($hex)};
     }
    
  4. 본격적으로 색상 변수 사용하기

     <html>
       <body class="theme-light daybreak">
         ...
       </body>
     </html>
    
     html:root {
       .theme-light {
         --Color-Primary-Button: var(--Gray1);
         --Hover-Text-Button: var(--Gray2);
         --Active-Text-Button: var(--Gray3);
       }
    
       .theme-dark {
         --Color-Primary-Button: var(--Gray13);
         --Hover-Text-Button: var(--Gray3);
         --Active-Text-Button: var(--Gray4);
       }
     }
    
     @mixin primary-button-color($color) {
       color: var(--Color-Primary-Button);
       background-color: var(--#{$color}6);
       border-color: var(--#{$color}6);
    
       .ui-button-icon path {
         fill: var(--Primary-Color-6);
       }
     }
    

    var(--Primary-Color-6)처럼 _themes.scss에서 선언한 변수를 그대로 사용할 수 있고, 라이트와 다크 모드에서 다른 변수를 가져오고 싶다면 root 가상 클래스로 새롭게 변수를 추가할 수 있다.

🔵 Libookrary에 테마 토글 기능 구현하기

  1. 로컬스토리지에 테마 상태 저장해서 사용하기

     export default function RootLayout({
       children,
     }: Readonly<{
       children: React.ReactNode
     }>) {
       return (
         <html lang="ko">
           <body className="polar">
             <Providers>...</Providers>
           </body>
         </html>
       )
     }
    

    body 에 색상 테마만 선언한다.

     function Providers({ children }: ProviderProps) {
       useEffect(() => {
         const colorMode = localStorage.getItem('theme') || 'theme-light'
         document.body.classList.add(colorMode)
       }, [])
    
       return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>
     }
    

    콘텐츠를 감싸는 Providers 컴포넌트가 렌더링되면 로컬스토리지의 theme 값을 가져와 body 클래스에 추가한다.

  2. 테마 토글 버튼 및 기능 구현하기

     function Header() {
       const handleChangeTheme = () => {
         if (localStorage.getItem('theme') === 'theme-light') {
           localStorage.setItem('theme', 'theme-dark')
           document.body.classList.add('theme-dark')
           document.body.classList.remove('theme-light')
         } else {
           localStorage.setItem('theme', 'theme-light')
           document.body.classList.add('theme-light')
           document.body.classList.remove('theme-dark')
         }
       }
    
       return (
         <header className="header">
             ...
             {/* 테마에 맞는 아이콘을 못찾음 🥲🔥 */}
             <IconButton icon="Fire" onClick={handleChangeTheme} />
             ...
         </header>
       )
     }
    

    버튼을 클릭하고 로컬스토리지 theme 값에 따라 라이트/다크 모드로 변경한다. 로컬스토리지 값만 변경하면 body가 렌더링되지 않으므로 body에 클래스 값을 바꾸는 작업을 해서 바로 결과가 나올 수 있도록 한다.

테마가 잘 바뀌는 것을 볼 수 있다.

하지만 여기서 끝이 아니다.

🔴 새로고침 할 때 깜빡이는 문제

새로고침하면 잠시 깜빡-하고 올바른 테마로 돌아간다. 이러한 현상은 라이트모드로 설정한 사용자에겐 큰 문제가 되지 않지만, 다크모드로 설정한 사용자에겐 치명적인 불편함을 안겨줄 수 있다.

그렇다면 이 문제는 왜 발생하는 걸까? 브라우저 렌더링 과정을 살펴보자.

  1. HTML과 CSS를 파싱한다.

  2. 이것을 바탕으로 DOM과 CSSOM 트리를 생성한다.

  3. 이러한 트리들이 각각 어떻게 결합되는지 나타내는 렌더 트리 과정을 거친다.

  4. 레이아웃을 계산한다.

  5. 마지막으로 실제 브라우저에 그려낸다.

앞서 코드에선 Provider 컴포넌트가 렌더링되면, 즉 5단계 실제 브라우저가 화면을 그려내면 테마를 적용했다.

이런 상황을 고려했을 때 2번 단계 전에 테마를 지정하면 깜빡이는 현상을 없앨 수 있을 것이라 생각한다.

🔵 렌더링 차단 리소스로 해결하기

브라우저 렌더링 과정에서 HTML을 파싱할 때 한 가지 특징이 있다. HTML 안에 script 태그가 존재하면 해석이 끝날 때까지 HTML 파싱이 차단되는 것이다. 이것을 렌더링 차단 리소스 특성이라고 한다.

<body>
  <script>
    // 테마 설정 리소스
  </script>
  <div>
    ... 
  </div>
</body>

이렇게 body 안에 script 태그를 추가하면 된다.

Next.js Page Router에서는 _document.tsx 파일을 만들어서 사용하지만, 라이북러리는 App Router이기 때문에 app/layout.tsx 파일에서 사용하면 된다.

// ./app/layout.tsx
const ScriptTheme = () => {
  const codeToRunOnClient = `(function() {
    const colorMode = localStorage.getItem('theme') || 'theme-light'
    document.body.classList.add(colorMode)
  })()`

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

export default function RootLayout(...) {
  return (
    <html lang="ko">
      <body className="polar">
        <ScriptTheme />
        <Providers>
          ...
        </Providers>
      </body>
    </html>
  )
}

컴포넌트는 스크립트를 직접적으로 반환할 수 없기 때문에 함수를 dangerouslySetInnerHTML에 넣어 클라이언트에서 실행될 수 있도록 한다.

Providers 컴포넌트에 작성한 테마 설정 코드는 더이상 필요없다.

브라우저 렌더링 과정과 SSR의 특성을 살려 문제를 해결하였다!


렌더링 차단 리소스 제거 https://developer.chrome.com/docs/lighthouse/performance/render-blocking-resources?hl=ko

JavaScript로 상호작용 추가 https://web.dev/articles/critical-rendering-path/adding-interactivity-with-javascript?hl=ko

웹에서 다크모드 지원하기 https://fe-developers.kakaoent.com/2021/211118-dark-mode/