훤다 블로그

SSR로 다크모드 구현하기

당신의 Next.js는 SSR이 맞을까?
Next152024.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. 파일 구조

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;

(참고사항) 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>
  );
};
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

'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;

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;

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.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>