🌗 다크 모드 구현 가이드: Context, <body> 클래스 활용
React Context API를 사용해 상태 전역으로 관리하고,
styled-components가 <body> 태그의 클래스를 감지하여 스타일을 동적으로 변경하는 고성능 방식1. 의존성 설치
테마 상태를
localStorage에 저장하고 <body> 클래스를 자동으로 관리해주는 use-dark-mode 라이브러리 사용# yarn yarn add @fisch0920/use-dark-mode # npm npm install @fisch0920/use-dark-mode
styled-components는 이미 설치되어 있다고 가정2. useDarkMode 훅 커스텀
라이브러리를 직접 사용하기보다, 프로젝트에 맞게 한 번 감싸서(wrapping) 사용하면 유연성이 높아짐.
dark 대신 dark-mode라는 클래스 이름을 사용하도록 설정lib/use-dark-mode.ts// 외부 라이브러리에서 실제 useDarkMode 구현체 가져옴 import useDarkModeImpl from '@fisch0920/use-dark-mode' /** * useDarkMode 훅을 프로젝트에 맞게 한 번 감싼(wrapped) 커스텀 훅 * 필요한 `isDarkMode`, `toggleDarkMode`만 뽑아서 반환 */ export function useDarkMode() { // 라이브러리의 훅을 실행 const darkMode = useDarkModeImpl( false, // 초기값(defaultValue)을 false(라이트 모드)로 설정 { classNameDark: 'dark-mode', // 옵션: 다크 모드일 때 <body>에 추가할 CSS 클래스 이름 classNameLight: 'light-mode', // (선택) 라이트 모드일 때 클래스 이름 } ) // 라이브러리가 반환하는 객체(darkMode)에서 필요한 값들만 추출하여 매핑한 새 객체 반환 return { isDarkMode: darkMode.value, // 라이브러리 'value' -> 'isDarkMode'라는 이름으로 변경 toggleDarkMode: darkMode.toggle // 라이브러리 'toggle' -> 'toggleDarkMode'라는 이름으로 변경 } }
3. 테마 Context 생성
앱 전역에서 테마 상태와 토글 함수를 공유할 "방송국(Context)" 만듦
contexts/Theme.tsximport React, { createContext, useContext } from 'react'; // 2단계에서 만든 커스텀 훅 가져옴 // 이 훅이 실제 로직(localStorage, <body> 클래스 제어) 담당 import { useDarkMode } from '@/lib/use-dark-mode'; // Context에 저장할 값의 타입(모양) 정의합니다. interface ThemeContextType { isDarkMode: boolean; // 현재 다크 모드인지 여부 toggleDarkMode: () => void; // 테마 토글하는 함수 } // Context 객체("방송국")를 생성 // Provider가 없을 때 기본값으로 undefined를 설정 const ThemeContext = createContext<ThemeContextType | undefined>(undefined); /** * Context 값 제공하는 Provider 컴포넌트 ("방송국") * 이 컴포넌트로 감싸진 모든 하위 컴포넌트(children)들은 * useTheme 훅을 통해 'value'로 제공된 값에 접근할 수 있음 */ export const ThemeProvider = ({ children }: { children: React.ReactNode }) => { // useDarkMode 훅을 호출하여 실제 상태와 토글 함수 가져옴 // -> ThemeProvider가 테마 상태를 소유하고 관리 const { isDarkMode, toggleDarkMode } = useDarkMode(); return ( // ThemeContext.Provider 렌더링 // 'value' prop으로 하위 컴포넌트들에게 공유할 { isDarkMode, toggleDarkMode } 객체 전달 <ThemeContext.Provider value={{ isDarkMode, toggleDarkMode }}> {children} {/* 하위 컴포넌트들을 그대로 렌더링 */} </ThemeContext.Provider> ); }; /** * Context 값을 쉽게 사용하기 위한 커스텀 훅 ("라디오 수신기") * 컴포넌트에서 이 훅 호출하면 가장 가까운 상위 ThemeProvider의 value 반환 */ export const useTheme = () => { // useContext 훅을 사용해 ThemeContext의 현재 값(value)을 가져옴 const context = useContext(ThemeContext); if (!context) { throw new Error('useTheme must be used within a ThemeProvider'); } // context 값({ isDarkMode, toggleDarkMode }) 반환 return context; };
4. 앱 전체에 Provider 적용
Next.js의
_app.tsx 파일에서 앱 전체를 ThemeProvider로 감싸, 모든 페이지와 컴포넌트가 테마 상태에 접근할 수 있도록 함pages/_app.tsximport type { AppProps } from 'next/app' // 3단계에서 만든 ThemeProvider 가져옴 import { ThemeProvider } from '@/contexts/Theme' import { GlobalStyle } from '@/styles/globalStyle' export default function App({ Component, pageProps }: AppProps) { return ( // ThemeProvider로 앱 컴포넌트 전체를 감쌈 <ThemeProvider> {/* GlobalStyle이나 Header처럼 앱 레이아웃의 일부가 ThemeProvider 내부에 위치해야 함 */} <GlobalStyle /> <Component {...pageProps} /> </ThemeProvider> ) }
5. styled-components에서 클래스 사용하기
styled-components 파일에서 body.dark-mode 셀렉터를 사용해 다크 모드 스타일 적용A. 전역 스타일 (Global Styles)
createGlobalStyle이나 global.css에서 CSS 변수를 정의하면 관리 편함styles/globalStyle.ts (예시)import { createGlobalStyle } from 'styled-components'; export const GlobalStyle = createGlobalStyle` /* 기본값 (라이트 모드) 변수 정의 */ :root { --bg-color: #FFFFFF; --text-primary: #111111; --text-secondary: #555555; --border-color: #EAEAEA; } /* body.dark-mode가 활성화되면 변수들을 덮어씀 */ body.dark-mode { --bg-color: #121212; --text-primary: #FFFFFF; --text-secondary: #AAAAAA; --border-color: #333333; } /* 변수 적용 */ body { background-color: var(--bg-color); color: var(--text-primary); transition: background-color 0.2s ease, color 0.2s ease; } `;
B. 개별 컴포넌트 (.style.ts)
@media (prefers-color-scheme: dark) 미디어 쿼리 사용하던 것 body.dark-mode &로 변경components/MyComponent/MyComponent.style.tsimport styled from 'styled-components' export const CardContainer = styled.div` /* 1. 라이트 모드 (기본) 스타일 */ background: white; border: 1px solid #EAEAEA; color: #111111; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); /* 2. 다크 모드 스타일 'body.dark-mode &' 의미: "body 태그에 dark-mode 클래스가 있을 때, 이 컴포넌트(&)의 스타일을 아래와 같이 적용해라" */ body.dark-mode & { background: #222222; border-color: #333333; color: #FFFFFF; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); } &:hover { border-color: #BBBBBB; body.dark-mode & { border-color: #555555; } } `;
6. 토글 버튼
마지막으로, 3단계에서 만든
useTheme 훅을 사용해 실제 토글 버튼을 만듭니다.components/Header/Header.tsximport React from 'react' import { IoSunny, IoMoon } from 'react-icons/io5' // 3단계에서 만든 "라디오 수신기" 훅 import { useTheme } from '@/contexts/Theme' import { ThemeToggleButton } from './Header.style' export const Header = () => { // useTheme 훅을 호출해 현재 상태와 토글 함수를 가져옴 const { isDarkMode, toggleDarkMode } = useTheme(); return ( <header> {/* ... 다른 헤더 요소들 ... */} {/* 2. 토글 함수를 버튼의 onClick 이벤트에 연결 */} <ThemeToggleButton onClick={toggleDarkMode}> {/* 3. isDarkMode 상태에 따라 아이콘을 조건부 렌더링 */} {isDarkMode ? <IoSunny /> : <IoMoon />} </ThemeToggleButton> </header> ) }
전체 동작
- 사용자가
Header의 토글 버튼을 클릭
onClick이벤트가useTheme()훅에서 받아온toggleDarkMode함수를 실행
toggleDarkMode는ThemeProvider가useDarkMode()훅에서 받아온toggle함수를 실행
- use-dark-mode 라이브러리의 toggle 함수가 두 가지 작업을 동시에 수행
A. <body> 태그에 dark-mode 클래스를 추가 (또는 제거)
B. 내부 React 상태(value)를 변경하고 localStorage에 저장
- 두 가지 반응
- 동작:
useDarkMode훅이<body>태그에dark-mode클래스를 붙임 - 스타일 제어: CSS가
body.dark-mode { ... }셀렉터를 보고 알아서 스타일을 전부 바꿈 - React 역할:
isDarkMode라는 상태(스위치가 켜졌는지)만 관리하고, 정작 스타일이 어떻게 변하는지는 전혀 모름. 그냥 클래스만 붙이고 끝 - 성능이 압도적으로 빠름. 스타일 변경을 브라우저의 네이티브 CSS 엔진이 처리. React 리렌더링이나 JavaScript 계산이 거의 필요 없음
- 단순. 스타일(CSS)과 로직(JS)이 명확하게 분리
- 유연성이 낮음. 'light', 'dark'처럼 정해진 2~3가지 모드 전환에는 좋지만, "사용자가 직접 색상을 고르는" 동적 테마에는 부적합
- 동작
- Zustand 같은 전역 스토어에
{ mode: 'dark', colors: { background: '#111', text: '#fff' } }같은 테마 객체를 저장 styled-components의<ThemeProvider>가 이 객체를 앱 전체에 뿌림- 모든 컴포넌트가
color: ${props => props.theme.text};처럼 JS 코드를 통해 동적으로 스타일을 계산해서 적용 - 스타일 제어: JavaScript가 모든 색상 값을 일일이 계산하고 CSSOM에 주입
- React의 역할: 테마가 바뀌면, 스토어 값이 바뀌고,
<ThemeProvider>가 새 '레시피'를 전달하면 모든 컴포넌트가 스타일을 다시 계산(리렌더링) - 유연성이 매우 높음. 다크/라이트뿐만 아니라 '블루 테마', '핑크 테마', '고대비 모드', 심지어 사용자가 직접 색상을 고르는 기능까지 뭐든지 만들 수 있음
- 성능 부담이 있음. 테마가 바뀔 때마다 많은 컴포넌트가 JavaScript로 스타일을 다시 계산해야 해서, 현재 방식보다 훨씬 느릴 수 있음
- 복잡합니다. 모든 컴포넌트가
theme객체를 의존하게 되고, JS 코드 안에 스타일 로직이 섞이게 됨
A. (CSS 반응): 브라우저가 body.dark-mode 클래스를 감지하고, *.style.ts와 globalStyle.ts에 정의된 모든 body.dark-mode & 스타일을 즉시 적용
B. (React 반응): 내부 상태 변경이 useDarkMode → ThemeProvider로 전파. useTheme() 훅을 사용하던 Header 컴포넌트가 새로운 isDarkMode 값을 받고 리렌더링되어, 아이콘이 IoMoon에서 IoSunny로 변경
1. 현재 방식 (Context + <body> 클래스)
이 방식은 React(JavaScript)를 '스위치'로만 사용
👍 장점
👎 단점
2. 전역 상태 라이브러리 방식 (e.g., Zustand + styled-components)
이 방식은 JavaScript(React)를 '모든 스타일을 지시하는 중앙 관제실'로 사용