테마 변경 기능 구현
지금까지 배운 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가 변경되는 것을 확인합니다.
프로젝트 준비
create-react-app
으로 생성된 프로젝트가 있다고 가정합니다. src
폴더에 다음과 같은 구조로 파일들을 생성하고 코드를 작성하겠습니다.
src/
├── App.js
├── index.css (기본 스타일)
├── contexts/
│ └── AppContext.js <-- 상태와 dispatch를 위한 Context 정의 및 Provider 컴포넌트 포함
├── components/
│ ├── Header.js <-- 로그인/로그아웃 버튼, 테마 토글 버튼
│ ├── UserProfile.js <-- 로그인된 사용자 정보 표시
│ └── ThemeChanger.js <-- 테마에 따라 스타일 변경되는 컴포넌트
├── pages/
│ ├── HomePage.js
│ └── DashboardPage.js <-- 로그인해야 접근 가능한 페이지 (간단하게 구현)
전역 스타일링 (index.css
)
기본적인 스타일을 정의합니다.
/* src/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 컴포넌트를 만듭니다.
// src/contexts/AppContext.js
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
로그인/로그아웃 버튼과 테마 토글 버튼을 포함합니다.
// src/components/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
로그인된 사용자 정보를 표시합니다.
// src/components/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
테마에 따라 스타일이 변경되는 간단한 예시 컴포넌트입니다.
// src/components/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
// src/pages/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
// src/pages/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
로 전체 애플리케이션을 감싸고 라우팅을 설정합니다.
// src/App.js
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;
실습 진행 방법 및 확인 사항
- 프로젝트 생성 및 의존성 설치
npx create-react-app context-reducer-app
cd context-reducer-app
npm install react-router-dom
(혹은yarn add react-router-dom
)
- 파일 구조 생성: 위에 제시된 디렉토리 구조에 따라 파일들을 생성합니다.
- 코드 복사/붙여넣기: 각 파일에 해당하는 코드를 정확히 복사하여 붙여넣습니다. (특히
index.css
를 잊지 마세요!) - 애플리케이션 실행:
npm start
(또는yarn start
) 명령어를 실행하여 개발 서버를 시작합니다. - 기능 테스트
- 테마 변경: 상단 헤더의 "다크 모드" / "라이트 모드" 버튼을 클릭하여 전체 앱의 배경색, 텍스트색 등이 변경되는지 확인합니다. (
AppContext.js
의useEffect
와index.css
의 CSS 변수 활용) - 로그인/로그아웃
초기 상태에서 "로그인" 버튼을 클릭하여
환영합니다, ReactUser!
메시지와 함께 로그인 상태로 전환되는지 확인합니다.HomePage
에 있는 "사용자 프로필" 컴포넌트가 로그인 후 사용자 정보를 표시하는지 확인합니다. "로그아웃" 버튼을 클릭하여 다시 로그인되지 않은 상태로 돌아가는지 확인합니다. - 대시보드 접근
- 로그인되지 않은 상태에서 헤더의 "대시보드" 링크를 클릭하거나
http://localhost:3000/dashboard
로 직접 접근해봅니다. "로그인이 필요한 페이지입니다!" 알림 후 홈 페이지로 리다이렉트되는지 확인합니다. - 로그인한 상태에서 "대시보드" 페이지로 이동하여 대시보드 콘텐츠가 정상적으로 표시되는지 확인합니다.
- 로그인되지 않은 상태에서 헤더의 "대시보드" 링크를 클릭하거나
- 404 페이지:
http://localhost:3000/nonexistent-page
와 같이 존재하지 않는 URL로 접근하여 404 페이지가 잘 나타나는지 확인합니다.
- 테마 변경: 상단 헤더의 "다크 모드" / "라이트 모드" 버튼을 클릭하여 전체 앱의 배경색, 텍스트색 등이 변경되는지 확인합니다. (
이제 여러분은 useReducer
와 Context API를 결합하여 Redux의 핵심 개념인 단일 스토어, 디스패치, 리듀서를 통해 전역 상태를 관리하는 미니 시스템을 성공적으로 구현했습니다.
이 실습을 통해,
- 복잡한 상태 로직을
reducer
함수로 분리하여 관리하는 방법을 익혔습니다. - Context API를 통해
state
와dispatch
함수를 컴포넌트 트리의 깊이에 상관없이 효율적으로 전달하는 방법을 이해했습니다. - 실제 애플리케이션에서 로그인 상태, 테마 변경과 같은 전역 상태를 어떻게 관리할 수 있는지 경험했습니다.
이제 더 큰 규모의 프로젝트나 특정 요구사항에 맞는 다양한 상태 관리 라이브러리(Redux Toolkit, Recoil, Zustand 등)를 학습할 준비가 되었습니다.