icon
7장 : 상태 관리 입문

테마 변경 기능 구현

지금까지 배운 useState, useReducer, Context API를 총체적으로 활용하여 Redux와 유사한 형태의 전역 상태 관리 시스템을 직접 구축하는 실습을 진행하겠습니다.

이 실습은 Redux의 Store, Action, Reducer, Dispatch 개념이 실제로 어떻게 작동하는지 useReducer와 Context API 조합으로 구현해보면서 더욱 깊이 이해하는 데 도움이 될 것입니다. 또한, 소규모 또는 중간 규모의 애플리케이션에서 복잡한 외부 라이브러리 없이도 전역 상태 관리를 효과적으로 할 수 있는 패턴을 익힐 수 있습니다.


실습 목표

  1. 전역 Context 생성: 애플리케이션 전반에서 공유될 상태와 디스패치 함수를 위한 Context를 생성합니다.
  2. Reducer 함수 정의: 복잡한 상태 업데이트 로직을 처리할 Reducer 함수를 작성합니다.
  3. Provider 컴포넌트 생성: useReducer 훅을 사용하여 상태와 디스패치 함수를 만들고, Context.Provider를 통해 하위 컴포넌트에 제공하는 Wrapper 컴포넌트를 만듭니다.
  4. 전역 상태 사용: useContext 훅을 사용하여 Provider가 제공하는 상태와 디스패치 함수를 하위 컴포넌트에서 사용합니다.
  5. 예시 시나리오: 사용자 로그인 상태 관리, 테마 변경 기능을 통합하여 구현합니다.

시나리오: 사용자 로그인 및 테마 관리

다음과 같은 기능을 가진 애플리케이션을 만들 것입니다.

  • 사용자 로그인/로그아웃: 전역적으로 사용자 로그인 상태(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
/* 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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;

실습 진행 방법 및 확인 사항

  1. 프로젝트 생성 및 의존성 설치
    • npx create-react-app context-reducer-app
    • cd context-reducer-app
    • npm install react-router-dom (혹은 yarn add react-router-dom)
  2. 파일 구조 생성: 위에 제시된 디렉토리 구조에 따라 파일들을 생성합니다.
  3. 코드 복사/붙여넣기: 각 파일에 해당하는 코드를 정확히 복사하여 붙여넣습니다. (특히 index.css를 잊지 마세요!)
  4. 애플리케이션 실행: npm start (또는 yarn start) 명령어를 실행하여 개발 서버를 시작합니다.
  5. 기능 테스트
    • 테마 변경: 상단 헤더의 "다크 모드" / "라이트 모드" 버튼을 클릭하여 전체 앱의 배경색, 텍스트색 등이 변경되는지 확인합니다. (AppContext.jsuseEffectindex.css의 CSS 변수 활용)
    • 로그인/로그아웃 초기 상태에서 "로그인" 버튼을 클릭하여 환영합니다, ReactUser! 메시지와 함께 로그인 상태로 전환되는지 확인합니다. HomePage에 있는 "사용자 프로필" 컴포넌트가 로그인 후 사용자 정보를 표시하는지 확인합니다. "로그아웃" 버튼을 클릭하여 다시 로그인되지 않은 상태로 돌아가는지 확인합니다.
    • 대시보드 접근
      • 로그인되지 않은 상태에서 헤더의 "대시보드" 링크를 클릭하거나 http://localhost:3000/dashboard로 직접 접근해봅니다. "로그인이 필요한 페이지입니다!" 알림 후 홈 페이지로 리다이렉트되는지 확인합니다.
      • 로그인한 상태에서 "대시보드" 페이지로 이동하여 대시보드 콘텐츠가 정상적으로 표시되는지 확인합니다.
    • 404 페이지: http://localhost:3000/nonexistent-page와 같이 존재하지 않는 URL로 접근하여 404 페이지가 잘 나타나는지 확인합니다.

이제 여러분은 useReducer와 Context API를 결합하여 Redux의 핵심 개념인 단일 스토어, 디스패치, 리듀서를 통해 전역 상태를 관리하는 미니 시스템을 성공적으로 구현했습니다.

이 실습을 통해,

  • 복잡한 상태 로직을 reducer 함수로 분리하여 관리하는 방법을 익혔습니다.
  • Context API를 통해 statedispatch 함수를 컴포넌트 트리의 깊이에 상관없이 효율적으로 전달하는 방법을 이해했습니다.
  • 실제 애플리케이션에서 로그인 상태, 테마 변경과 같은 전역 상태를 어떻게 관리할 수 있는지 경험했습니다.

이제 더 큰 규모의 프로젝트나 특정 요구사항에 맞는 다양한 상태 관리 라이브러리(Redux Toolkit, Recoil, Zustand 등)를 학습할 준비가 되었습니다.