해당 글은 Next.js 14버전을 사용한 Project를 기반으로 작성하였습니다.
복습
- CSR과 SSR : CSR과 SSR
- 다크모드 구현하기(1) : CSR로 다크모드 구현하기
결론
저는 프로젝트에선 첫 페이지 로드 속도와 SEO 에 더 중점을 두고 있기 때문에, CSR 방식보다는 SSR 방식 을 사용하는 것이 더 적합하다고 생각합니다. 또한, Client Component 가 컴포넌트 트리에서 상위레벨에 있어 Javascript 번들크기가 커지는 것을 방지하고, SSR의 효율성을 극대화 하기 위해 CSR 방식은 사용하지 않기로 결정 했습니다.
결과물
사전 참고사항(필수X)
1. lucide-react 패키지 설치
lucide-react
패키지를 설치합니다.
이 라이브러리는 SVG 아이콘을 쉽게 사용할 수 있게 해줍니다.
npm install lucide-react --save-dev
2. 파일 구조
- component를
client
와server
로 나누어 구성합니다. - User의 interaction에 따라 모드가 바뀌는 부분(
Dropdown
과ThemeSwitch
)은 client에 위치합니다. - 모드에 따라 변하지 않는 부분(
Header
)은 server에 위치합니다.
3. .env 설정
이번에는 next-themes
를 사용하는 ThemeProvider
가 없이, layout
과 ThemeSwitch
컴포넌트에서 cookies
를 사용하여 모드를 관리합니다.
Server, Client component 모두 cookies
를 사용할 수 있기 때문에, cookies
를 사용하여 모드를 관리하면 CSR과 SSR에서 모드를 유지 할 수 있습니다.
루트 디렉토리에 .env
파일을 생성하고, NEXT_PUBLIC_THEME_COOKIE_NAME
을 추가합니다.
NEXT_PUBLIC_THEME_COOKIE_NAME=project_name_theme
SSR로 theme 구현하기
1. layout.tsx와 cookies
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/headers
의cookies()
함수는 서버 사이드에서 동작합니다. - 해당 쿠키를 가져와서
theme
에 따라html
에 클래스를 추가합니다. - 이를 통해 다른 사이트에 방문했다가 되돌아오더라도 쿠키를 통해 이전에 선택한 테마를 유지할 수 있습니다.
(참고사항) Dropdown 구현하기
저는 Dropdown
컴포넌트를 사용하여 다크모드를 구현했습니다만, toggle, button 등 다른 방법으로 구현해도 무방합니다.
'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
컴포넌트의 상태를 관리합니다.
'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
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 시- 자동으로 code spliting되어 별도의 Javascript 번들로 생성됩니다.
- 하이드레이션 오류 방지 및 지연로딩됩니다.
- 여기서 쓰진 않았지만, 조건부 로딩이 가능하게 합니다.
4. tailwind.config.ts 수정
이제 컴포넌트에서는 className
에 dark:
클래스를 추가하여 다크모드를 활성화할 수 있습니다.
<div className='dark:bg-black dark:text-white'>
Dark mode
</div>
tailwind.config.ts
에서 theme를 쓴다면, darkMode
를 class
로 설정합니다.
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ['class'],
//...
};
한편, 위 코드는 문제점을 해결한 코드입니다. 제가 경험한 문제는 다음과 같습니다.
문제점과 해결
1. 페이지 새로고침 시, 깜빡임 발생
middleware.ts를 써야되지 않나?
처음엔, server component에서는 브라우저(client)의 cookies를 사용할 수 없을거라고 생각했습니다. 그래서 미들웨어를 만들어, server component가 생성되기 전에 cookies를 가져오려고 했습니다.
- middleware 실행순서
middleware.ts
실행- 매치되는 라우트 결정 (페이지 또는 API 라우트)
- 서버 컴포넌트 렌더링 (해당하는 경우)
- getServerSideProps 또는 API 라우트 핸들러 (해당하는 경우)
그래서 다음과 같은 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 적용
};
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
에서 쿠키를 가져오는 방식으로 변경했습니다.
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 문제
- Monitor 아이콘을 사용하고 default 로 설정해서, 사용자 입장에서 해당 버튼이 무엇을 의미하는지 인지하기 어려웠습니다.
- 테마가 변경 될 때, 아이콘이 너무 정적 으로 바뀌어서 부드러운 느낌을 주고 싶었습니다.
<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>
- Monitor 아이콘을 사용하지 않고, system 테마를 의미하는 아이콘을
css
로 설정했습니다. (dark:) - 컴포넌트를 미리 렌더링 해놓고,
css
로transition
과rotate
를 주어 UI 변경이 부드럽게 느껴지도록 수정했습니다.
<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>