테마 변경 기능 구현
지금까지 배운 useState, useReducer, Context API를 종합적으로 활용해 Redux와 유사한 형태의 전역 상태 관리 시스템을 직접 구축하는 실습을 진행하겠습니다.
이 실습은 Redux의 Store, Action, Reducer, Dispatch 개념이 실제로 어떻게 작동하는지 useReducer와 Context API 조합으로 구현해 보면서 더 깊이 이해하도록 돕습니다.
또한 소규모 또는 중간 규모 애플리케이션에서 복잡한 외부 라이브러리 없이도 전역 상태를 효과적으로 관리하는 패턴을 익힐 수 있습니다.
실습 목표: 인증/테마 전역 상태 운용
이번 실습은 실무 사고 기준으로 진행합니다. 인증 상태가 바뀌는 경계 시점(로그인 직후, 로그아웃 직후, 보호 라우트 직접 접근)에서 어떤 버그가 나는지 먼저 가정하고 구조를 설계합니다.
전역 Context 생성: 애플리케이션 전반에서 공유될 상태와 디스패치 함수를 위한 Context를 생성합니다.
Reducer 함수 정의: 복잡한 상태 업데이트 로직을 처리할 Reducer 함수를 작성합니다.
Provider 컴포넌트 생성: useReducer 훅을 사용하여 상태와 디스패치 함수를 만들고, Context.Provider를 통해 하위 컴포넌트에 제공하는 Wrapper 컴포넌트를 만듭니다.
전역 상태 사용: useContext 훅을 사용하여 Provider가 제공하는 상태와 디스패치 함수를 하위 컴포넌트에서 사용합니다.
예시 시나리오: 사용자 로그인 상태 관리, 테마 변경 기능을 통합하여 구현합니다.
시나리오: 사용자 로그인 및 테마 관리
다음과 같은 기능을 가진 애플리케이션을 만들 것입니다.
- 사용자 로그인/로그아웃: 전역적으로 사용자 로그인 상태(
isLoggedIn,user)를 관리합니다. - 테마 변경: 다크 모드/라이트 모드 테마를 전역적으로 변경합니다.
- 로그인 상태와 테마에 따라 UI가 변경되는 것을 확인합니다.
준비 단계: 전역 상태 경계 설정
준비 단계의 선택 기준은 명확합니다. 여러 페이지에서 반복되는 인증/테마 상태는 전역 Context로 통합하고, 화면별 일시 상태는 페이지 로컬 상태로 남겨 불필요한 전역화를 피합니다.
Vite로 생성된 프로젝트가 있다고 가정합니다. src 폴더에 다음과 같은 구조로 파일들을 생성하고 코드를 작성하겠습니다.
Context 예제용 전역 스타일 정리 (index.css)
기본적인 스타일을 정의합니다.
body {
font-family: 'Arial', sans-serif;
margin: 0;
padding: 0;
background-color: #f4f7f6;
color: #333;
line-height: 1.6;
}
#root {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
flex-grow: 1;
padding: 20px;
max-width: 960px;
margin: 20px auto;
background-color: var(--background-color-main, #ffffff); /* 기본값 */
color: var(--text-color-main, #333); /* 기본값 */
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
transition: background-color 0.3s ease, color 0.3s ease;
}
h1, h2, h3 {
color: var(--header-color, #2c3e50); /* 기본값 */
}
.button {
display: inline-block;
padding: 10px 20px;
background-color: #3498db;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
text-decoration: none;
font-size: 1em;
transition: background-color 0.2s ease;
}
.button:hover {
background-color: #2980b9;
}
.button.secondary {
background-color: #7f8c8d;
}
.button.secondary:hover {
background-color: #616e78;
}
/* 테마 변수 정의 (CSS 변수 활용) */
body.light-theme {
--background-color-main: #ffffff;
--text-color-main: #333;
--header-color: #2c3e50;
--header-bg: #eee;
--header-text: #333;
--card-bg: #fdfdfd;
--card-border: #eee;
}
body.dark-theme {
--background-color-main: #333;
--text-color-main: #eee;
--header-color: #eee;
--header-bg: #222;
--header-text: #eee;
--card-bg: #444;
--card-border: #555;
}var(--variable-name, default-value)를 사용하여 CSS 변수를 정의하고,body태그의 클래스를 변경하여 테마를 전환할 것입니다.
전역 상태 Context 및 Reducer 정의
이 파일에서 전역 상태를 위한 Context, Reducer, 그리고 이들을 통합하는 Provider 컴포넌트를 만듭니다.
import React, { createContext, useReducer, useEffect } from 'react';
// 1. 초기 상태 정의
const initialState = {
isLoggedIn: false,
user: null,
theme: 'light', // 기본 테마
};
// 2. Reducer 함수 정의
function appReducer(state, action) {
switch (action.type) {
case 'LOGIN':
return {
...state,
isLoggedIn: true,
user: action.payload,
};
case 'LOGOUT':
return {
...state,
isLoggedIn: false,
user: null,
};
case 'TOGGLE_THEME':
return {
...state,
theme: state.theme === 'light' ? 'dark' : 'light',
};
default:
throw new Error(`Unhandled action type: ${action.type}`);
}
}
// 3. Context 생성
// Context를 통해 상태와 dispatch 함수를 하위 컴포넌트에 전달할 것입니다.
export const AppContext = createContext();
// 4. Provider 컴포넌트 생성 (실제로 useReducer와 Context를 연결하는 부분)
export const AppProvider = ({ children }) => {
const [state, dispatch] = useReducer(appReducer, initialState);
// 테마 변경 시 body 클래스 업데이트
useEffect(() => {
document.body.className = state.theme === 'dark' ? 'dark-theme' : 'light-theme';
}, [state.theme]);
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
);
};컴포넌트
Header.js
로그인/로그아웃 버튼과 테마 토글 버튼을 포함합니다.
import React, { useContext } from 'react';
import { AppContext } from '../contexts/AppContext'; // AppContext 임포트
import { Link } from 'react-router-dom';
function Header() {
const { state, dispatch } = useContext(AppContext); // Context 값 사용
const { isLoggedIn, user, theme } = state;
const handleLoginLogout = () => {
if (isLoggedIn) {
dispatch({ type: 'LOGOUT' });
alert('로그아웃 되었습니다.');
} else {
// 실제 로그인 로직 (API 호출 등)이 필요하지만, 여기서는 가상 사용자 정보 사용
const mockUser = { name: 'ReactUser', email: 'user@example.com' };
dispatch({ type: 'LOGIN', payload: mockUser });
alert(`${mockUser.name}님, 환영합니다!`);
}
};
const handleToggleTheme = () => {
dispatch({ type: 'TOGGLE_THEME' });
};
return (
<header style={{
backgroundColor: `var(--header-bg)`,
color: `var(--header-text)`,
padding: '15px 20px',
boxShadow: '0 2px 5px rgba(0,0,0,0.1)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
<Link to="/" style={{ textDecoration: 'none', color: `var(--header-text)`, fontSize: '1.5em', fontWeight: 'bold' }}>My App</Link>
<Link to="/dashboard" style={{ textDecoration: 'none', color: `var(--header-text)`, fontSize: '1.1em' }}>대시보드</Link>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
{isLoggedIn ? (
<span style={{ fontSize: '1.1em' }}>환영합니다, {user.name}!</span>
) : (
<span style={{ fontSize: '1.1em' }}>로그인해주세요.</span>
)}
<button
onClick={handleLoginLogout}
className="button"
style={{ backgroundColor: isLoggedIn ? '#e74c3c' : '#2ecc71', padding: '8px 15px', fontSize: '0.9em' }}
>
{isLoggedIn ? '로그아웃' : '로그인'}
</button>
<button
onClick={handleToggleTheme}
className="button secondary"
style={{ padding: '8px 15px', fontSize: '0.9em' }}
>
{theme === 'light' ? '다크 모드' : '라이트 모드'}
</button>
</div>
</header>
);
}
export default Header;UserProfile.js
로그인된 사용자 정보를 표시합니다.
import React, { useContext } from 'react';
import { AppContext } from '../contexts/AppContext';
function UserProfile() {
const { state } = useContext(AppContext);
const { isLoggedIn, user } = state;
if (!isLoggedIn) {
return (
<div style={{ padding: '20px', border: '1px dashed var(--card-border)', borderRadius: '8px', backgroundColor: 'var(--card-bg)', textAlign: 'center', color: '#888' }}>
<p>로그인 후 사용자 정보를 볼 수 있습니다.</p>
</div>
);
}
return (
<div style={{ padding: '20px', border: '1px solid var(--card-border)', borderRadius: '8px', backgroundColor: 'var(--card-bg)', boxShadow: '0 2px 5px rgba(0,0,0,0.03)' }}>
<h3 style={{ color: 'var(--header-color)' }}>사용자 프로필</h3>
<p><strong>이름:</strong> {user.name}</p>
<p><strong>이메일:</strong> {user.email}</p>
</div>
);
}
export default UserProfile;ThemeChanger.js
테마에 따라 스타일이 변경되는 간단한 예시 컴포넌트입니다.
import React, { useContext } from 'react';
import { AppContext } from '../contexts/AppContext';
function ThemeChanger() {
const { state } = useContext(AppContext);
const { theme } = state;
return (
<div style={{
padding: '20px',
marginTop: '20px',
border: '1px solid var(--card-border)',
borderRadius: '8px',
backgroundColor: 'var(--card-bg)',
boxShadow: '0 2px 5px rgba(0,0,0,0.03)',
textAlign: 'center',
}}>
<h3 style={{ color: 'var(--header-color)' }}>테마 적용 예시</h3>
<p>현재 테마는 <span style={{ fontWeight: 'bold', color: theme === 'light' ? 'blue' : 'lightblue' }}>{theme}</span> 모드입니다.</p>
<p>이 컴포넌트는 Context API를 통해 테마를 받아 스타일을 변경합니다.</p>
</div>
);
}
export default ThemeChanger;화면별 컨텍스트 적용 페이지
HomePage.js
import React from 'react';
import UserProfile from '../components/UserProfile';
import ThemeChanger from '../components/ThemeChanger';
function HomePage() {
return (
<div style={{ padding: '20px' }}>
<h2 style={{ textAlign: 'center', marginBottom: '30px' }}>홈 페이지</h2>
<UserProfile />
<ThemeChanger />
</div>
);
}
export default HomePage;DashboardPage.js
import React, { useContext } from 'react';
import { AppContext } from '../contexts/AppContext';
import { useNavigate } from 'react-router-dom';
import { useEffect } from 'react';
function DashboardPage() {
const { state } = useContext(AppContext);
const { isLoggedIn, user } = state;
const navigate = useNavigate();
useEffect(() => {
if (!isLoggedIn) {
alert('로그인이 필요한 페이지입니다!');
navigate('/'); // 로그인되지 않았다면 홈으로 리다이렉트
}
}, [isLoggedIn, navigate]);
if (!isLoggedIn) {
return null; // 리다이렉트 중에는 아무것도 렌더링하지 않음
}
return (
<div style={{ padding: '20px', backgroundColor: 'var(--card-bg)', borderRadius: '8px', border: '1px solid var(--card-border)' }}>
<h2 style={{ textAlign: 'center', marginBottom: '30px', color: 'var(--header-color)' }}>
{user.name}님의 대시보드
</h2>
<p>이곳은 로그인한 사용자만 볼 수 있는 중요한 정보들이 표시되는 공간입니다.</p>
<ul>
<li>사용자 ID: {user.email}</li>
<li>최근 활동: 2025-06-18</li>
<li>보안 등급: 높음</li>
</ul>
<p style={{ marginTop: '20px', color: '#666' }}>
이 페이지는 `useContext`를 통해 `isLoggedIn` 상태를 확인하고, 로그인되어 있지 않으면 자동으로 홈으로 리다이렉트됩니다.
</p>
</div>
);
}
export default DashboardPage;App.js (최종 설정)
AppProvider로 전체 애플리케이션을 감싸고 라우팅을 설정합니다.
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AppProvider } from './contexts/AppContext'; // AppProvider 임포트
import Header from './components/Header';
import HomePage from './pages/HomePage';
import DashboardPage from './pages/DashboardPage';
import NotFoundPage from './pages/NotFoundPage'; // 7장 실습에서 만든 404 페이지 재사용
function App() {
return (
// AppProvider로 전체 애플리케이션을 감싸서 Context 값을 제공
<AppProvider>
<BrowserRouter>
<Header /> {/* Header는 항상 표시 */}
<div className="main-content"> {/* 모든 페이지 내용이 들어갈 컨테이너 */}
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/dashboard" element={<DashboardPage />} />
{/* 404 페이지는 항상 맨 마지막에 */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
</div>
</BrowserRouter>
</AppProvider>
);
}
export default App;검증 순서 및 확인 사항: 보호 라우트 회귀 점검
실습 검증은 비로그인 접근 차단 -> 로그인 허용 -> 로그아웃 후 재차단 순서로 고정합니다. 이 흐름으로 테스트하면 보호 라우트의 회귀를 빠르게 감지할 수 있습니다.
npm create vite@latest context-reducer-app -- --template reactcd context-reducer-appnpm install react-router-dom(혹은yarn add react-router-dom)
파일 구조 생성: 위에 제시된 디렉토리 구조에 따라 파일들을 생성합니다.
코드 복사/붙여넣기: 각 파일에 해당하는 코드를 정확히 복사하여 붙여넣습니다. (특히 index.css를 잊지 마세요!)
애플리케이션 실행: npm run dev (또는 yarn dev) 명령어를 실행하여 개발 서버를 시작합니다.
- 테마 변경: 상단 헤더의 다크 모드 / 라이트 모드 버튼을 클릭하여 전체 앱의 배경색, 텍스트색 등이 변경되는지 확인합니다. (
AppContext.js의useEffect와index.css의 CSS 변수 활용) -
로그인/로그아웃
- 초기 상태에서 로그인 버튼을 클릭하여
환영합니다, ReactUser!메시지와 함께 로그인 상태로 전환되는지 확인합니다. HomePage에 있는 사용자 프로필 컴포넌트가 로그인 후 사용자 정보를 표시하는지 확인합니다.- 로그아웃 버튼을 클릭하여 다시 로그인되지 않은 상태로 돌아가는지 확인합니다.
- 초기 상태에서 로그인 버튼을 클릭하여
-
대시보드 접근
- 로그인되지 않은 상태에서 헤더의 대시보드 링크를 클릭하거나
http://localhost:5173/dashboard로 직접 접근해봅니다. 로그인이 필요한 페이지입니다! 알림 후 홈 페이지로 리다이렉트되는지 확인합니다. - 로그인한 상태에서 대시보드 페이지로 이동하여 대시보드 콘텐츠가 정상적으로 표시되는지 확인합니다.
- 로그인되지 않은 상태에서 헤더의 대시보드 링크를 클릭하거나
- 404 페이지:
http://localhost:5173/nonexistent-page와 같이 존재하지 않는 URL로 접근하여 404 페이지가 잘 나타나는지 확인합니다.
이제 여러분은 useReducer와 Context API를 결합하여 Redux의 핵심 개념인 단일 스토어, 디스패치, 리듀서를 통해 전역 상태를 관리하는 미니 시스템을 성공적으로 구현했습니다.
이 실습을 통해,
- 복잡한 상태 로직을
reducer함수로 분리하여 관리하는 방법을 익혔습니다. - Context API를 통해
state와dispatch함수를 컴포넌트 트리의 깊이에 상관없이 효율적으로 전달하는 방법을 이해했습니다. - 실제 애플리케이션에서 로그인 상태, 테마 변경과 같은 전역 상태를 어떻게 관리할 수 있는지 경험했습니다.
이제 더 큰 규모의 프로젝트나 특정 요구사항에 맞는 다양한 상태 관리 라이브러리(Redux Toolkit, Recoil, Zustand 등)를 학습할 준비가 되었습니다.