훤다 블로그

SSR로 다크모드 구현하기

당신의 Next.js는 SSR이 맞을까?
Next
152024.10.01
SSR로 다크모드 구현하기

해당 글은 Next.js 14버전을 사용한 Project를 기반으로 작성하였습니다.

복습

결론

저는 프로젝트에선 첫 페이지 로드 속도와 SEO 에 더 중점을 두고 있기 때문에, CSR 방식보다는 SSR 방식 을 사용하는 것이 더 적합하다고 생각합니다. 또한, Client Component 가 컴포넌트 트리에서 상위레벨에 있어 Javascript 번들크기가 커지는 것을 방지하고, SSR의 효율성을 극대화 하기 위해 CSR 방식은 사용하지 않기로 결정 했습니다.

결과물

사전 참고사항(필수X)

1. lucide-react 패키지 설치

lucide-react패키지를 설치합니다. 이 라이브러리는 SVG 아이콘을 쉽게 사용할 수 있게 해줍니다.

npm install lucide-react --save-dev

2. 파일 구조

  • component를 clientserver로 나누어 구성합니다.
  • User의 interaction에 따라 모드가 바뀌는 부분(DropdownThemeSwitch)은 client에 위치합니다.
  • 모드에 따라 변하지 않는 부분(Header)은 server에 위치합니다.

3. .env 설정

이번에는 next-themes를 사용하는 ThemeProvider가 없이, layoutThemeSwitch 컴포넌트에서 cookies를 사용하여 모드를 관리합니다.

Server, Client component 모두 cookies를 사용할 수 있기 때문에, cookies를 사용하여 모드를 관리하면 CSR과 SSR에서 모드를 유지 할 수 있습니다.

루트 디렉토리에 .env 파일을 생성하고, NEXT_PUBLIC_THEME_COOKIE_NAME을 추가합니다.

.env
NEXT_PUBLIC_THEME_COOKIE_NAME=project_name_theme

SSR로 theme 구현하기

1. layout.tsx와 cookies

layout.tsx
import './globals.css';
import Header from '@/components/server/common/Header';
import Footer from '@/components/server/common/Footer';
import { cookies } from 'next/dist/client/components/headers';
 
//...
 
const RootLayout = ({ children }: RootLayoutProps) => {
  const themeCookieName = process.env.NEXT_PUBLIC_THEME_COOKIE_NAME || '';
  const themeCookie = cookies().get(themeCookieName)?.value;
 
  return (
    <html lang='en' className={themeCookie === 'light' ? '' : 'dark'}>
      <body className='bg-theme text-theme'>
        <Header />
        <main className='mt-[64px]'>{children}</main>
        <Footer />
      </body>
    </html>
  );
};
 
export default RootLayout;
  • Next.js 13 이상에서 next/headerscookies() 함수는 서버 사이드에서 동작합니다.
  • 해당 쿠키를 가져와서 theme에 따라 html에 클래스를 추가합니다.
  • 이를 통해 다른 사이트에 방문했다가 되돌아오더라도 쿠키를 통해 이전에 선택한 테마를 유지할 수 있습니다.

(참고사항) Dropdown 구현하기

저는 Dropdown 컴포넌트를 사용하여 다크모드를 구현했습니다만, toggle, button 등 다른 방법으로 구현해도 무방합니다.

DropdownProvider.tsx
'use client';
 
import { createContext, useState, ReactNode } from 'react';
 
export const DropdownContext = createContext({
  isOpen: false,
  toggle: () => {},
  close: () => {},
});
 
export const DropdownProvider = ({ children }: { children: ReactNode }) => {
  const [isOpen, setIsOpen] = useState(false);
 
  const toggle = () => setIsOpen(!isOpen);
  const close = () => setIsOpen(false);
 
  return (
    <DropdownContext.Provider value={{ isOpen, toggle, close }}>
      <div className='relative inline-block text-left'>{children}</div>
    </DropdownContext.Provider>
  );
};
  • DropdownProvider는 Context를 사용하여 Dropdown 컴포넌트의 상태를 관리합니다.
Dropdown.tsx
'use client';
 
import { useContext, ReactNode, useRef, useEffect } from 'react';
import {
  DropdownContext,
  DropdownProvider,
} from '@/components/client/ui/DropdownProvider';
 
const Dropdown = ({ children }: { children: ReactNode }) => {
  return <DropdownProvider>{children}</DropdownProvider>;
};
 
// DropdownTrigger 컴포넌트
const DropdownTrigger = ({ children }: { children: React.ReactElement }) => {
  const { toggle } = useContext(DropdownContext);
 
  return <div onClick={toggle}>{children}</div>;
};
 
// DropdownList 컴포넌트
const DropdownList = ({
  children,
  align = 'start',
}: {
  children: ReactNode;
  align?: 'start' | 'end';
}) => {
  const { isOpen, close } = useContext(DropdownContext);
  const contentRef = useRef<HTMLDivElement>(null);
 
  // 메뉴 외부를 클릭했을 때 닫기 위한 이벤트 핸들러
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      // 이벤트 타겟이 HTMLElement인지를 확인하여 타입 안전성 보장
      if (event.target instanceof HTMLElement) {
        // contentRef가 가리키는 요소 외부에서 클릭되었는지 확인
        if (contentRef.current && !contentRef.current.contains(event.target)) {
          close();
        }
      }
    };
 
    document.addEventListener('mousedown', handleClickOutside);
    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [close]);
 
  return isOpen ? (
    <div
      ref={contentRef}
      className={`absolute z-50 mt-2 min-w-36 rounded-lg bg-white shadow-lg ring-1 ring-black/5 transition duration-200 ease-out ${
        align === 'end' ? 'right-0' : 'left-0'
      }`}
      onClick={close} // 메뉴 아이템 클릭 시 메뉴를 닫기 위해 사용
    >
      {children}
    </div>
  ) : null;
};
 
// DropdownItem 컴포넌트
const DropdownItem = ({
  children,
  onClick,
  className,
}: {
  children: React.ReactElement;
  onClick?: ()=> void;
  className?: string;
}) => {
  const { close } = useContext(DropdownContext);
 
  return (
    <button
      className={`flex w-full items-center rounded-lg px-4 py-2 text-left text-sm text-gray-700 transition-colors duration-150 ease-in-out hover:bg-gray-100 hover:text-gray-900 ${ className }`}
      onClick={() => {
        if (onClick) onClick();
        close();
      }}
    >
      {children}
    </button>
  );
};
 
export {
  DropdownContext,
  Dropdown,
  DropdownTrigger,
  DropdownList,
  DropdownItem,
};

2. ThemeSwitch.tsx와 cookies

  • 사용자 동작(Dropdown)으로 재렌더링되는 client component ThemeSwitch를 구현합니다.
  • 해당 코드는 길기 때문에 잘 따라오시기 바랍니다. 중요한 로직이라 생각되어 생략하지 않았습니다.
'use client';
 
import { useEffect, useState } from 'react';
import {Dropdown, DropdownList, DropdownItem, DropdownTrigger} from '@/components/client/ui/Dropdown';
import { LucideIcon, Dot, Monitor, Moon, Sun } from 'lucide-react';
import { useRouter } from 'next/navigation';
 
interface DropdownItemProps {
  newTheme: string;
  label: string;
  Icon: LucideIcon;
}
 
const ThemeSwitch = () => {
  // 컴포넌트가 마운트되었는지 확인하는 상태
  const [mounted, setMounted] = useState(false);
  // 현재 선택된 테마를 저장하는 상태
  const [currentTheme, setCurrentTheme] = useState<string>('');
  // 환경 변수에서 테마 쿠키 이름을 가져옴
  const themeCookieName = process.env.NEXT_PUBLIC_THEME_COOKIE_NAME || '';
  const router = useRouter();
 
  useEffect(() => {
    setMounted(true); // 컴포넌트가 마운트되었음을 표시
 
    // 브라우저의 쿠키에서 테마 설정을 가져옴
    const themeCookie = document.cookie.match(
      new RegExp(`(?:^|; )${ themeCookieName }=([^;]*)`)
    )?.[1];
 
    if (themeCookie) {
      setCurrentTheme(themeCookie);
      // 저장된 테마에 따라 dark 클래스를 추가하거나 제거
      if (themeCookie === 'dark') {
        document.documentElement.classList.add('dark');
      } else if (themeCookie === 'light') {
        document.documentElement.classList.remove('dark');
      } else {
        // 시스템 테마 설정을 확인하고 반영
        const systemPrefersDark = window.matchMedia(
          '(prefers-color-scheme: dark)'
        ).matches;
        document.documentElement.classList.toggle('dark', systemPrefersDark);
      }
    } else {
      // 쿠키가 없을 경우 기본값으로 'system' 설정
      document.cookie = `${ themeCookieName }=system; path=/;`;
      document.documentElement.classList.remove('dark');
    }
 
    // 시스템 테마 변경을 감지하는 이벤트 리스너
    const handleSystemThemeChange = (e: MediaQueryListEvent) => {
      const prefersDark = e.matches;
      document.cookie = `darkMode=${ prefersDark ? 'true' : 'false' }; path=/;`;
      document.documentElement.classList.toggle('dark', prefersDark);
    };
 
    // 시스템 테마 변경 감지를 위한 이벤트 리스너 등록
    window
      .matchMedia('(prefers-color-scheme: dark)')
      .addEventListener('change', handleSystemThemeChange);
 
    // 컴포넌트 언마운트 시 이벤트 리스너 제거
    return () => {
      window
        .matchMedia('(prefers-color-scheme: dark)')
        .removeEventListener('change', handleSystemThemeChange);
    };
  }, [themeCookieName]);
 
  // 테마 변경 함수
  const handleSetTheme = (newTheme: string) => {
    setCurrentTheme(newTheme);
    // 새 테마를 쿠키에 저장
    document.cookie = `${ themeCookieName }=${ newTheme }; path=/;`;
 
    // 선택된 테마에 따라 dark 클래스 토글
    if (newTheme === 'dark') {
      document.documentElement.classList.add('dark');
    } else if (newTheme === 'light') {
      document.documentElement.classList.remove('dark');
    } else {
      // 시스템 테마 설정 반영
      const systemPrefersDark = window.matchMedia(
        '(prefers-color-scheme: dark)'
      ).matches;
      document.documentElement.classList.toggle('dark', systemPrefersDark);
    }
    router.refresh(); // 페이지 새로고침으로 변경사항 적용
  };
 
  // 각 테마 옵션을 렌더링하는 컴포넌트
  const ThemeItem = ({ newTheme, Icon, label }: DropdownItemProps) => (
    <DropdownItem onClick={() => handleSetTheme(newTheme)}>
      <div className='flex w-full items-center justify-between'>
        <div className='flex items-center gap-2'>
          <Icon width={14} />
          {label}
        </div>
        {currentTheme === newTheme && <Dot className='text-end' />}
      </div>
    </DropdownItem>
  );
 
  // 컴포넌트가 마운트되기 전에는 null 반환
  if (!mounted) return null;
 
  // 드롭다운 메뉴를 사용한 테마 선택 UI 렌더링
  return (
    <Dropdown>
      <DropdownTrigger>
        <button className='flex rounded-md p-2 hover:bg-gray-200 dark:hover:bg-gray-600'>
          <Sun className='size-5 rotate-180 scale-100 transition-all dark:-rotate-90 dark:scale-0' />
          <Moon className='absolute size-5 rotate-180 scale-0 transition-all dark:rotate-0 dark:scale-100' />
        </button>
      </DropdownTrigger>
      <DropdownList align='end'>
        <ThemeItem newTheme='light' label='Light' Icon={Sun} />
        <ThemeItem newTheme='dark' label='Dark' Icon={Moon} />
        <ThemeItem newTheme='system' label='System' Icon={Monitor} />
      </DropdownList>
    </Dropdown>
  );
};
 
export default ThemeSwitch;
  • 쿠키를 사용하여 사용자의 테마 선호도를 저장하고 불러옵니다.
  • 테마 변경 시 DOM을 직접 조작하여 테마를 적용합니다.
  • 테마 변경 시 쿠키를 업데이트하고, HTML 클래스를 조작하며, 라우터를 새로고침합니다.
  • 시스템(OS) 테마 변경을 실시간으로 감지하고 반영합니다.
  • 커스텀 Dropdown 컴포넌트를 사용하여 테마 선택 UI를 구성합니다.

3. Header.tsx

Header.tsx
import dynamic from 'next/dynamic';
 
// ThemeSwitch를 클라이언트 컴포넌트로 동적 로딩
const ThemeSwitch = dynamic(
  () => import('@/components/client/theme/ThemeSwitch'),
  { ssr: true }
);
 
const Header = () => {
  return (
    <header className='bg-theme fixed left-0 top-0 z-50 w-full shadow'>
      {/* ... */}
      <ThemeSwitch />
    </header>
  );
};
 
export default Header;
  • dynamic import 시
    1. 자동으로 code spliting되어 별도의 Javascript 번들로 생성됩니다.
    2. 하이드레이션 오류 방지 및 지연로딩됩니다.
    3. 여기서 쓰진 않았지만, 조건부 로딩이 가능하게 합니다.

4. tailwind.config.ts 수정

이제 컴포넌트에서는 classNamedark: 클래스를 추가하여 다크모드를 활성화할 수 있습니다.

Example.tsx
<div className='dark:bg-black dark:text-white'>
  Dark mode
</div>

tailwind.config.ts에서 theme를 쓴다면, darkModeclass로 설정합니다.

tailwind.config.ts
import type { Config } from 'tailwindcss';
 
const config: Config = {
  darkMode: ['class'],
  //...
};

한편, 위 코드는 문제점을 해결한 코드입니다. 제가 경험한 문제는 다음과 같습니다.

문제점과 해결

1. 페이지 새로고침 시, 깜빡임 발생

middleware.ts를 써야되지 않나?

처음엔, server component에서는 브라우저(client)의 cookies를 사용할 수 없을거라고 생각했습니다. 그래서 미들웨어를 만들어, server component가 생성되기 전에 cookies를 가져오려고 했습니다.

  • middleware 실행순서
    1. middleware.ts 실행
    2. 매치되는 라우트 결정 (페이지 또는 API 라우트)
    3. 서버 컴포넌트 렌더링 (해당하는 경우)
    4. getServerSideProps 또는 API 라우트 핸들러 (해당하는 경우)

그래서 다음과 같은 middleware.ts를 만들었습니다.

middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
 
export function middleware(request: NextRequest) {
  const themeCookieName = process.env.NEXT_PUBLIC_THEME_COOKIE_NAME || '';
  const themeCookie = request.cookies.get(themeCookieName)?.value;
  const response = NextResponse.next();
 
  if (themeCookie === 'dark') {
    response.headers.set('OS-Theme', 'dark');
  } else if (themeCookie === 'light') {
    response.headers.set('OS-Theme', 'light');
  } else {
    // 클라이언트 힌트를 사용하여 사용자 선호 색상 모드를 감지
    const prefersDark
      = request.headers.get('sec-ch-prefers-color-scheme') === 'dark';
    response.headers.set('OS-Theme', prefersDark ? 'dark' : 'light');
  }
 
  return response;
}
 
export const config = {
  matcher: '/', // 모든 경로에 대해 middleware 적용
};
layout.tsx
import { headers } from 'next/headers';
 
const RootLayout = ({ children }: RootLayoutProps) => {
  const themeHeader = headers().get('OS-Theme');
  const isDarkMode = themeHeader === 'dark';
 
  return (
    <html lang='en' className={isDarkMode === 'dark' ? 'dark' : ''}>
      {/* ... */}
    </html>
  );
}

이렇게 사용하니, 페이지 리로드 시 깜빡임이 발생했습니다.

headers().get('OS-Theme')미들웨어에서 설정한 커스텀 헤더를 읽으려고 시도 합니다. 이 헤더가 일관되게 설정되지 않았거나, 클라이언트 사이드 네비게이션 시 업데이트되지 않을 수 있습니다. 결과적으로, 페이지 리로드 시 서버와 클라이언트의 상태가 불일치 할 수 있어 깜빡임이 발생했던 것입니다.

그래서 미들웨어를 삭제하고, layout.tsx에서 쿠키를 가져오는 방식으로 변경했습니다.

layout.tsx
import { cookies } from 'next/dist/client/components/headers';
 
//...
 
const RootLayout = ({ children }: RootLayoutProps) => {
  const themeCookieName = process.env.NEXT_PUBLIC_THEME_COOKIE_NAME || '';
  const themeCookie = cookies().get(themeCookieName)?.value;
 
  return (
    <html lang='en' className={themeCookie === 'light' ? '' : 'dark'}>
      {/* ... */}
    </html>
  );
};

주의할 점은 cookies() 함수는 읽기 전용입니다. 쿠키를 수정하려면 API 라우트나 서버 액션을 사용해야 합니다. (저는 client component에서 쿠키를 수정했습니다.)

2. UI 문제

  1. Monitor 아이콘을 사용하고 default 로 설정해서, 사용자 입장에서 해당 버튼이 무엇을 의미하는지 인지하기 어려웠습니다.
  2. 테마가 변경 될 때, 아이콘이 너무 정적 으로 바뀌어서 부드러운 느낌을 주고 싶었습니다.
이전 ThemeSwitch.tsx
<DropdownTrigger>
  <button className='flex rounded-md p-2 hover:bg-gray-200 dark:hover:bg-gray-600'>
    {currentTheme === 'light' ? (
      <Sun className='size-4' />
    ) : currentTheme === 'dark' ? (
      <Moon className='size-4' />
    ) : (
      <Monitor className='size-4' />
    )}
  </button>
</DropdownTrigger>
  1. Monitor 아이콘을 사용하지 않고, system 테마를 의미하는 아이콘을 css로 설정했습니다. (dark:)
  2. 컴포넌트를 미리 렌더링 해놓고, csstransitionrotate를 주어 UI 변경이 부드럽게 느껴지도록 수정했습니다.
수정 ThemeSwitch.tsx
<DropdownTrigger>
  <button className='flex rounded-md p-2 hover:bg-gray-200 dark:hover:bg-gray-600'>
    <Sun className='size-5 rotate-180 scale-100 transition-all dark:-rotate-90 dark:scale-0' />
    <Moon className='absolute size-5 rotate-180 scale-0 transition-all dark:rotate-0 dark:scale-100' />
  </button>
</DropdownTrigger>