네부캠 그룹 프로젝트 마감 기한이 빡빡해서, 포스팅할 시간이 없었다.
지금부터 조금씩 정리했던 내용들을 포스팅하려고 한다.
우리 팀은 '잇다-' 라는 서비스를 만들고 있다. github
개인의 기록을 넘어, 함께 만드는 추억을 위한 기록 서비스
기록 어플은 시중에 나와있는 서비스가 정말 많다.
나는 그 많은 서비스 중에서 유저의 유입을 고려한다면 디자인이 가장 중요하다고 생각했다.
나만 봐도, 디자인이 별로면 아무리 기능이 뛰어나도 바로 삭제한다.
그래서 디자인적으로 고민을 많이 했고, 다크 모드를 많이 사용하는 사람으로서 다크모드도 넣는게 좋을 것 같았다.
그래서 이번엔 다크모드를 지원하는 과정을 포스팅하려고 한다.
tailwind에서 다크/라이트 모드를 제공하는 방법은 간단하다.
class 에 dark:* 로 다크모드일 때의 스타일을 지정해주면 된다.
<span className="dark:text-gray-200 text-itta-black">
다크 모드
</span>
그리고 다크 모드로 제공하고자 할 때는 html 태그의 class 에 dark 를 추가해주면 된다.
<html lang="ko" className="dark">
html 태그에 해당 class가 존재하냐에 따라 테마를 다르게 지원할 수 있다.
물론 tailwind 버전에 맞게 class로 테마를 설정하는 코드를 작성해줘야 할 수도 있다.
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
이 부분은 공식문서를 참고하면 좋을 것 같다.
여기에서 고려해줘야 할 부분이 있다.
매번 html 태그에 dark 를 수동으로 추가해줄 순 없기 때문에, 스크립트를 작성해줘야 한다.
유저가 어떤 페이지에 접근하는지 알 수 없기 때문에, 모든 페이지를 고려해 테마 설정은 Context API 를 주로 사용한다.
또한 새로고침을 해도 테마가 유지되어야 하기에 localStorage 와 같은 스토리지에 테마 값을 저장한다.
테마가 적용되는 흐름은 다음과 같다. (next 기준)
이 과정에서 문제가 발생하게 된다.
HTML 로드와 테마 적용 사이에 시간차가 발생하면서 화면이 깜빡이게 된다.
네트워크 지연 혹은 성능이 좋지 않은 디바이스에선 더 두드러질 것이다.
이 문제를 해결하는 방법 중 하나는 blocking script 를 사용하는 것이다.
<html>
<head>
<script dangerouslySetInnerHTML={{
__html: `
(function() {
try {
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
} catch (e) {}
})();
`
}} />
</head>
<body>
<ThemeProvider> {/* Context API */}
{children}
</ThemeProvider>
</body>
</html>
blocking script 는 React가 로딩되기 전, HTML 파싱 중 즉시 실행되며 React hydration을 기다리지 않는다.
그렇기에 <html> 태그에 바로 dark 클래스를 추가할 수 있어 깜빡임 문제를 개선할 수 있다.
직접 구현하는 것도 충분히 가능하지만, blocking script 로직과 여러 예외 처리를 직접 작성해줘야 한다.
또한 localStorage 에서 값을 가져오고 저장하는 로직도 추가로 작성해줘야 한다.
이런 번거로움을 줄이고자 next 테마를 설정하도록 지원해주는 라이브러리가 있는데, 그게 next-themes 이다.
번들 사이즈도 약 1.5kB로 작기 때문에, 시간과 안정성 측면에서 활용하는게 좋을 것 같다고 판단했다.

Next.js에서 다크모드를 비롯한 테마 변경을 쉽게 구현할 수 있도록 하는 라이브러리
클라이언트 측에서 localStorage 또는 class 속성을 사용해 테마를 적용하고 유지하도록 지원한다.
useTheme() 훅을 사용해 테마를 간편하게 변경할 수 있음localStorage로 유지class 기반 테마를 지원해 tailwindCSS를 활용할 때 유용pnpm install next-themes
이제 layout에서 ThemeProvider 를 사용할 수 있으며, 이를 통해서 서비스 전체의 테마 색상을 변경할 수 있다.
ThemeProvider는 서버 컴포넌트가 아닌 클라이언트 컴포넌트이다.
하지만 Provider 하위의 모든 컴포넌트가 CSR로 바뀌는 것은 아니다.
서버에서ThemeProvider포함 전체 HTML을 SSR에서 생성하고, 브라우저에서 해당 HTML을 받아 hydration 과정을 거치게 된다.
layout.tsx 파일을 다음과 같이 수정해준다.
import { ThemeProvider } from "next-themes" ;
export default function RootLayout ( {
children,
}: Readonly<{
children: React.ReactNode;
}> ) {
return (
<html lang = "en" suppressHydrationWarning>
<body className = {`${ geistSans.variable} antialiased`}>
<ThemeProvider attribute="class" enableSystem={true} defaultTheme="system">
{children}
</ThemeProvider >
</body >
</html >
);
}
suppressHydrationWarning 속성을 추가하지 않으면 next-themes element가 업데이트될 때 경고가 표시된다.
서버 컴포넌트 환경에서 클라이언트가 테마를 결정하며 속성을 바꿀 때 발생하는 불일치 경고를 해결하기 위함이다.
특히 다크 모드처럼 클라이언트에서만 확정되는 값이 HTML 속성(class)에 반영될 때 자주 발생
한 단계 깊이까지만 적용되기 때문에, 다른 element의 hydration 경고는 차단하지 않는다.
enableSystem={true}next-themes 가 테마를 시스템 설정에 맞게 조정한다.defaultTheme="system"attribute="class"attribute)에 기록할지 결정한다.next-themes 가 테마를 바꿀 때마다 <html> 태그에 <html class="dark"> 또는 <html class="light"> 를 자동으로 넣어준다.테마에 맞는 적절한 색상을 지정하고 싶을 땐 .global.css 파일을 적절히 수정하면 된다.
나는 class 로 테마를 수정하므로 다음과 같이 설정해줬다. (기본)
@custom-variant dark (&:is(.dark *));
이제 테마를 변경하는 토글 버튼을 추가하자
import { useTheme } from 'next-themes';
import { cn } from '@/lib/utils';
import { useState, useEffect } from 'react';
export default function Setting() {
const router = useRouter();
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
// React 19의 cascading renders 에러 방지를 위한 지연 처리
const raId = requestAnimationFrame(() => {
setMounted(true);
});
return () => cancelAnimationFrame(raId);
}, []);
// Hydration 불일치를 방지하기 위해 마운트되기 전에는 아무것도 렌더링하지 않음
if (!mounted) {
return null;
}
const toggleDarkMode = () => {
setTheme(theme === 'dark' ? 'light' : 'dark');
};
const currentTheme = resolvedTheme;
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg transition-colors dark:bg-purple-500/10 dark:text-purple-400 bg-yellow-50 text-yellow-500">
{currentTheme === 'dark' ? (
<Moon className="w-4 h-4" />
) : (
<Sun className="w-4 h-4" />
)}
</div>
<span className="text-sm font-bold dark:text-gray-200 text-itta-black">
다크 모드
</span>
</div>
<button
onClick={toggleDarkMode}
className={cn(
'cursor-pointer w-11 h-6 rounded-full relative transition-all duration-300',
!mounted
? 'bg-gray-200'
: currentTheme === 'dark'
? 'bg-purple-500'
: 'bg-gray-200',
)}
>
<div
className={cn(
'absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-300 ease-in-out',
mounted && currentTheme === 'dark' && 'translate-x-5',
)}
/>
</button>
</div>
)
useThemenext-themes 에서 제공하는 훅을 통해서 현재 테마 상태(theme )를 읽고, 원하는 테마로 변경(setTheme )할 수 있다.useEffect 안에서 동기적으로 setState() 를 호출하면,requestAnimationframe 을 활용해 마운트 후 상태 업데이트 다음 repaint 직전으로 지연시켜주었다.
디자인을 수정하기 전에 찍었던 영상이라 현재 배포 버전과 레이아웃이 다른 부분이 있다.
mov -> gif 변환하면서 영상이 많이 느려졌다.