본문 바로가기

테마 변경 기능 구현

useReducer와 Context를 결합해 로그인·로그아웃과 테마 전환 상태를 여러 화면에서 일관되게 관리합니다.

지금까지 배운 useState, useReducer, Context API를 종합적으로 활용해 AppProvider가 상태를 소유하고 하위 화면이 같은 값을 읽는 구조를 구현하겠습니다.

이 실습은 action과 순수 reducer로 변경 이유를 표현하고, useReducer의 state와 dispatch를 Context로 전달합니다. 실제 Redux의 외부 Store, selector별 구독 비교, middleware·DevTools를 구현한 것은 아닙니다.

또한 소규모 또는 중간 규모 애플리케이션에서 복잡한 외부 라이브러리 없이도 전역 상태를 효과적으로 관리하는 패턴을 익힐 수 있습니다.


실습 목표: 인증/테마 전역 상태 운용

이번 실습은 실무 사고 기준으로 진행합니다.

인증 상태가 바뀌는 경계 시점(로그인 직후, 로그아웃 직후, 보호 라우트 직접 접근)에서 어떤 버그가 나는지 먼저 가정하고 구조를 설계합니다.

전역 Context 생성: 애플리케이션 전반에서 공유될 상태와 디스패치 함수를 위한 Context를 생성합니다.

Reducer 함수 정의: 복잡한 상태 업데이트 로직을 처리할 Reducer 함수를 작성합니다.

Provider 컴포넌트 생성: useReducer 훅을 사용하여 상태와 디스패치 함수를 만들고, Context.Provider를 통해 하위 컴포넌트에 제공하는 Wrapper 컴포넌트를 만듭니다.

전역 상태 사용: useContext 훅을 사용하여 Provider가 제공하는 상태와 디스패치 함수를 하위 컴포넌트에서 사용합니다.

예시 시나리오: 사용자 로그인 상태 관리, 테마 변경 기능을 통합하여 구현합니다.


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

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

  • 사용자 로그인/로그아웃: 전역적으로 사용자 로그인 상태(isLoggedIn, user)를 관리합니다.
  • 테마 변경: 다크 모드/라이트 모드 테마를 전역적으로 변경합니다.
  • 로그인 상태와 테마에 따라 UI가 변경되는 것을 확인합니다.

준비 단계: 전역 상태 경계 설정

준비 단계의 선택 기준은 명확합니다.

여러 페이지에서 반복되는 인증/테마 상태는 전역 Context로 통합하고, 화면별 일시 상태는 페이지 로컬 상태로 남겨 불필요한 전역화를 피합니다.

Vite로 생성된 프로젝트가 있다고 가정합니다.

src 폴더에 다음과 같은 구조로 파일들을 생성하고 코드를 작성하겠습니다.

App.js
index.css # 기본 스타일
AppContext.js # 상태와 dispatch를 위한 Context 정의 및 Provider 컴포넌트 포함
Header.js # 로그인/로그아웃 버튼, 테마 토글 버튼
UserProfile.js # 로그인된 사용자 정보 표시
ThemeChanger.js # 테마에 따라 스타일 변경되는 컴포넌트
HomePage.js
DashboardPage.js # 로그인해야 접근 가능한 페이지 (간단하게 구현)

Context 예제용 전역 스타일 정리 (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;
}
  • body의 테마 클래스에서 custom property를 정의하고, var(--variable-name, default-value)로 값을 읽습니다. 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>
  );
};

테마 변경은 reducer에서 theme 값만 바꾸는 것으로 끝나지 않습니다.

변경된 상태가 useEffect를 통해 body 클래스가 되고, CSS 변수가 실제 화면 색상으로 적용되는 전파 경로까지 함께 봐야 합니다.

테마 토글이 Context와 Effect를 거쳐 CSS에 반영된다 — 상태 추적

TOGGLE_THEME의 light→dark 뒤 Context 렌더와 commit 후 body Effect/CSS 변수 적용은 다른 시점과 경로다. 같은 theme/DOM class/화면 값을 추적해 consumer가 Effect를 호출하는 오해를 줄인다.

테마 state는 소비자 렌더와 Effect의 두 경로로 반영됩니다

REACT STATE · RENDER & EFFECT

테마 state는 소비자 렌더와 Effect의 두 경로로 반영됩니다

AppProvider가 state를 소유합니다. 새 Context value를 읽는 소비자 렌더와 commit 뒤 body 클래스를 바꾸는 Effect는 동일한 theme을 각자의 역할로 반영합니다.

실제 light → dark · 화면에 도달하는 값

TOGGLE_THEME → state.theme = dark
Context → 소비자가 dark 읽기
Effect → body.className = dark-theme
CSS: --header-bg = #222

header는 var(--header-bg)를 읽습니다. body의 기본 background-color는 #f4f7f6으로 고정되어 있으므로 모든 body 색상이 자동으로 바뀐다고 해석하지 않습니다.

테마 state의 Context와 Effect 두 경로Header action으로 reducer가 theme을 바꿉니다. AppProvider의 렌더에서 Context 소비자가 새 값을 받고, commit 뒤 theme Effect가 body 클래스를 바꿉니다. body 테마 변수와 var()를 읽는 요소에 CSS 색상이 적용됩니다. 소비자가 Effect를 호출하는 흐름은 아닙니다.HeaderTOGGLE_THEMEappReducertheme: light→ darkAppProvider새 state로 렌더Effect · commit 뒤[state.theme] →body.classNameContext consumers새 value →state.theme = darkCSS 변수 → 사용 요소body.dark-themevar()를 읽는 요소동일한 새 state에서 두 경로Header · ThemeChanger
테마 state의 Context와 Effect 두 경로Header action으로 reducer가 theme을 바꿉니다. AppProvider의 렌더에서 Context 소비자가 새 값을 받고, commit 뒤 theme Effect가 body 클래스를 바꿉니다. body 테마 변수와 var()를 읽는 요소에 CSS 색상이 적용됩니다. 소비자가 Effect를 호출하는 흐름은 아닙니다.HeaderTOGGLE_THEMEappReducertheme: light→ darkAppProvider새 state로 렌더Context consumers새 value →state.theme = darkEffect · commit 뒤[state.theme] →body.classNameCSS 변수→ 사용 요소body.dark-themevar()를 읽는 요소Header · ThemeChanger동일한 새 stateContext · Effect 두 경로

1 · Header 사건

TOGGLE_THEME을 dispatch합니다.

2 · 순수 appReducer

light를 dark로 바꾸고 새 state를 반환합니다. 외부 작업은 하지 않습니다.

3 · AppProvider 렌더

새 state로 렌더합니다. Context의 새 value를 읽는 Header·ThemeChanger 등 소비자와 아래 Effect가 같은 state에서 이어집니다.

4A · Context 소비자

새 state.theme을 읽어 테마 문구·버튼·조건부 색상을 렌더합니다. 소비자가 Effect를 호출하지 않습니다.

4B · commit 뒤 Effect

[state.theme]이 바뀌면 body.className을 dark-theme으로 덮어씁니다.

5 · CSS 변수와 색상

body.dark-theme의 변수들이 상속되고 var()를 사용하는 header·card·main 색상에 반영됩니다.

저장·복원 경계

저장소 복원과 system 테마 선택은 구현하지 않습니다. 새로고침하면 로그인 false와 theme light로 다시 시작합니다.

본문의 단일 Context

인증과 테마를 { state, dispatch } 객체 하나로 제공합니다. 로그인 변경도 해당 Context의 다른 소비자에게 새 value를 전달할 수 있습니다. Effect는 theme 의존성으로 실행됩니다.

클래스·보안 경계

Effect는 body.className 전체를 덮어씁니다. 다른 body 클래스와 병행하려면 소유 범위를 조정합니다. 예제 로그인은 고정 사용자 UI이며 client redirect가 서버 권한 검증을 대신하지 않습니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
light 현재 값state.theme: light / body: light-themeProvider의 state와 body의 light 표시가 일치한다렌더와 외부 DOM의 연결은 서로 다른 경로다
toggle 뒤 renderstate.theme: dark / consumer: darkreducer의 dark를 Context 소비자가 새 value로 읽는다소비자가 Effect를 직접 호출하는 흐름은 아니다
commit 뒤 Effectbody: dark-theme / CSS: 변수 사용 요소 갱신theme Effect가 body class를 바꾸고 var 사용 요소에 색을 적용한다state 변경과 body 표현의 시점을 분리한다

테마 토글이 Context와 Effect를 거쳐 CSS에 반영된다 — 상태 추적

TOGGLE_THEME의 light→dark 뒤 Context 렌더와 commit 후 body Effect/CSS 변수 적용은 다른 시점과 경로다. 같은 theme/DOM class/화면 값을 추적해 consumer가 Effect를 호출하는 오해를 줄인다.

테마 state는 소비자 렌더와 Effect의 두 경로로 반영됩니다

REACT STATE · RENDER & EFFECT

테마 state는 소비자 렌더와 Effect의 두 경로로 반영됩니다

AppProvider가 state를 소유합니다. 새 Context value를 읽는 소비자 렌더와 commit 뒤 body 클래스를 바꾸는 Effect는 동일한 theme을 각자의 역할로 반영합니다.

실제 light → dark · 화면에 도달하는 값

TOGGLE_THEME → state.theme = dark
Context → 소비자가 dark 읽기
Effect → body.className = dark-theme
CSS: --header-bg = #222

header는 var(--header-bg)를 읽습니다. body의 기본 background-color는 #f4f7f6으로 고정되어 있으므로 모든 body 색상이 자동으로 바뀐다고 해석하지 않습니다.

테마 state의 Context와 Effect 두 경로Header action으로 reducer가 theme을 바꿉니다. AppProvider의 렌더에서 Context 소비자가 새 값을 받고, commit 뒤 theme Effect가 body 클래스를 바꿉니다. body 테마 변수와 var()를 읽는 요소에 CSS 색상이 적용됩니다. 소비자가 Effect를 호출하는 흐름은 아닙니다.HeaderTOGGLE_THEMEappReducertheme: light→ darkAppProvider새 state로 렌더Effect · commit 뒤[state.theme] →body.classNameContext consumers새 value →state.theme = darkCSS 변수 → 사용 요소body.dark-themevar()를 읽는 요소동일한 새 state에서 두 경로Header · ThemeChanger
테마 state의 Context와 Effect 두 경로Header action으로 reducer가 theme을 바꿉니다. AppProvider의 렌더에서 Context 소비자가 새 값을 받고, commit 뒤 theme Effect가 body 클래스를 바꿉니다. body 테마 변수와 var()를 읽는 요소에 CSS 색상이 적용됩니다. 소비자가 Effect를 호출하는 흐름은 아닙니다.HeaderTOGGLE_THEMEappReducertheme: light→ darkAppProvider새 state로 렌더Context consumers새 value →state.theme = darkEffect · commit 뒤[state.theme] →body.classNameCSS 변수→ 사용 요소body.dark-themevar()를 읽는 요소Header · ThemeChanger동일한 새 stateContext · Effect 두 경로

1 · Header 사건

TOGGLE_THEME을 dispatch합니다.

2 · 순수 appReducer

light를 dark로 바꾸고 새 state를 반환합니다. 외부 작업은 하지 않습니다.

3 · AppProvider 렌더

새 state로 렌더합니다. Context의 새 value를 읽는 Header·ThemeChanger 등 소비자와 아래 Effect가 같은 state에서 이어집니다.

4A · Context 소비자

새 state.theme을 읽어 테마 문구·버튼·조건부 색상을 렌더합니다. 소비자가 Effect를 호출하지 않습니다.

4B · commit 뒤 Effect

[state.theme]이 바뀌면 body.className을 dark-theme으로 덮어씁니다.

5 · CSS 변수와 색상

body.dark-theme의 변수들이 상속되고 var()를 사용하는 header·card·main 색상에 반영됩니다.

저장·복원 경계

저장소 복원과 system 테마 선택은 구현하지 않습니다. 새로고침하면 로그인 false와 theme light로 다시 시작합니다.

본문의 단일 Context

인증과 테마를 { state, dispatch } 객체 하나로 제공합니다. 로그인 변경도 해당 Context의 다른 소비자에게 새 value를 전달할 수 있습니다. Effect는 theme 의존성으로 실행됩니다.

클래스·보안 경계

Effect는 body.className 전체를 덮어씁니다. 다른 body 클래스와 병행하려면 소유 범위를 조정합니다. 예제 로그인은 고정 사용자 UI이며 client redirect가 서버 권한 검증을 대신하지 않습니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
light 현재 값state.theme: light / body: light-themeProvider의 state와 body의 light 표시가 일치한다렌더와 외부 DOM의 연결은 서로 다른 경로다
toggle 뒤 renderstate.theme: dark / consumer: darkreducer의 dark를 Context 소비자가 새 value로 읽는다소비자가 Effect를 직접 호출하는 흐름은 아니다
commit 뒤 Effectbody: dark-theme / CSS: 변수 사용 요소 갱신theme Effect가 body class를 바꾸고 var 사용 요소에 색을 적용한다state 변경과 body 표현의 시점을 분리한다

컴포넌트

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: '[email protected]' };
      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;

검증 순서 및 확인 사항: 보호 라우트 회귀 점검

실습 검증은 비로그인 접근 차단 -> 로그인 허용 -> 로그아웃 후 재차단 순서로 고정합니다.

이 흐름으로 테스트하면 보호 라우트의 회귀를 빠르게 감지할 수 있습니다.

프로젝트 생성 및 의존성 설치
  • npm create vite@latest context-reducer-app -- --template react
  • cd context-reducer-app
  • npm 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 페이지가 잘 나타나는지 확인합니다.

검증이 끝난 뒤에는 구현 파일을 기능별로 다시 읽으며 상태의 소유권이 흔들리지 않는지 확인합니다.

이 실습의 로그인은 고정 사용자로 UI 상태를 바꾸는 모형입니다. 대시보드의 클라이언트 리다이렉트는 서버 데이터 접근 권한을 대신하지 않습니다.

진입 파일과 빠진 화면까지 연결하기

이 절은 앞의 기본 라우터와 블로그의 client fallback을 선수 학습 내용으로 사용합니다. 최종 App의 NotFoundPage import는 후자의 화면을 재사용합니다. 원 코드 주석의 “7장 실습”은 이 앞선 라우팅 실습을 가리키는 것으로 읽고, 파일이 없다면 다음 최소 화면을 준비합니다.

src/pages/NotFoundPage.jsx
import { Link } from 'react-router-dom';

export default function NotFoundPage() {
  return (
    <section>
      <h2>페이지를 찾을 수 없습니다.</h2>
      <Link to="/">홈으로 이동</Link>
    </section>
  );
}

앞 파일 표제의 .js는 원 예제의 이름을 유지한 것입니다. 기본 Vite React 프로젝트에서는 JSX 처리에 맞춰 JSX가 든 여덟 파일(App, AppContext, 세 components, 세 pages)을 내용 그대로 .jsx로 저장합니다. 확장자를 생략한 import는 해당 파일을 찾으며, 같은 이름의 .js·.jsx 두 파일을 함께 남기지 않습니다. 이 절의 원 import는 react-router-dom이므로 앞 설치 단계의 해당 패키지를 실습 프로젝트에서 연결합니다. 앞 장에서 react-router를 쓴다는 사실만으로 이 import 경로도 존재한다고 가정하지 않습니다.

App 안에 BrowserRouter가 이미 있으므로 진입 파일에서 라우터를 다시 감싸지 않습니다. Vite의 index.html에 있는 id="root" 요소와 다음 진입을 연결하고 기본 데모용 App.css 대신 이 실습의 index.css만 먼저 적용합니다.

src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

두 상태 필드와 화면 결과를 함께 추적하기

처음 isLoggedIn=false, user=null, theme='light'입니다. 로그인 버튼은 고정 ReactUser 객체를 LOGIN의 payload로 넘기므로 다음 상태에서는 isLoggedIn=true와 user가 함께 바뀝니다. 프로필과 헤더가 같은 사용자 정보를 읽는 이유입니다. TOGGLE_THEME은 인증 두 필드를 유지하고 테마만 바꾸며, LOGOUT은 사용자 두 필드를 비우되 테마는 유지합니다.

테마를 dark로 바꾸면 ThemeChanger 문구와 다음 모드 버튼은 렌더의 새 값을 표시합니다. commit 뒤 Effect가 body.dark-theme을 적용하면 CSS 변수를 읽는 header·카드·main의 색상이 바뀝니다. 원 body의 배경 #f4f7f6 자체는 고정되어 있습니다. 모든 배경이 자동으로 dark가 되는 실습으로 해석하지 않습니다.

비로그인 /dashboard는 먼저 null을 렌더해 사용자 내용을 숨기고 Effect가 홈으로 이동합니다. 개발 Strict Mode의 Effect 재실행에서는 alert가 반복될 수 있으므로 알림 횟수 하나를 인증 성공·실패의 기준으로 삼지 않습니다. 직접 URL 입력·새로고침의 첫 문서 응답은 호스트가 맡으므로, 배포 시에는 앱 경로의 SPA fallback과 서버의 실제 권한 검증을 별도로 준비합니다.

좁은 화면의 버튼도 같은 상태를 바꾸게 하기

헤더의 원 두 flex 그룹은 로그인 문구·링크·버튼을 한 줄에 놓습니다. 실습 index.css 끝에 다음 규칙을 추가하면 기존 값과 버튼 동작을 바꾸지 않고 좁은 화면에서 행을 나눌 수 있습니다.

src/index.css (추가)
header {
  flex-wrap: wrap;
  gap: 12px;
}

header > div {
  flex-wrap: wrap;
  min-inline-size: 0;
  max-inline-size: 100%;
}

.main-content {
  box-sizing: border-box;
  min-inline-size: 0;
  overflow-wrap: anywhere;
}

320px·375px에서 홈·대시보드 링크와 두 상태 버튼에 모두 접근할 수 있는지 확인합니다. 줄바꿈 후에도 같은 로그인·테마 상태를 읽어야 하며 CSS 수정으로 reducer의 입력 조건을 바꾸지 않습니다.

순서가 달라도 보존되는 필드 풀기

초기 화면에서 다크 모드 전환, 로그인, 대시보드 이동, 로그아웃을 순서대로 수행한다고 가정합니다. 각 단계의 theme, isLoggedIn, user와 로그아웃 직후 대시보드 본문의 결과를 적습니다. 그 상태에서 새로고침하면 세 필드가 어떻게 시작하는지, client fallback 화면이 보인다는 사실로 서버의 HTTP 404나 접근 권한까지 확인할 수 있는지도 구별합니다.

전이 필드·렌더·외부 책임을 나누는 풀이

첫 전환 뒤는 dark/false/null, 로그인 뒤는 dark/true/ReactUser입니다. 대시보드 이동은 상태 변경 action이 아니므로 세 필드를 유지하며 로그인 사용자 화면을 표시합니다. 로그아웃 뒤는 dark/false/null입니다. 본문은 null을 반환해 사용자 내용을 숨기고 Effect가 홈으로 이동합니다. LOGOUT 분기에는 theme을 초기화하는 규칙이 없습니다.

저장·복원 코드가 없으므로 새로고침의 초기값은 light/false/null입니다. 호스트가 앱 문서를 반환했을 때 client route를 선택할 수 있으며, wildcard UI는 응답의 HTTP 상태를 바꾸지 않습니다. 이 모형의 로그인이나 클라이언트 이동은 서버 API의 접근 권한을 검증하지 않습니다.


이제는 useReducer와 Context API를 결합해 한 컴포넌트가 소유하는 상태와 순수 전이 규칙을 여러 화면에 연결했습니다. 실제 외부 Redux store와 이 컴포넌트 소유 상태의 책임은 구별합니다.

이 실습을 통해,

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

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


이 코드에는 상태 저장·복원 기능이 없으므로 새로고침하면 로그인은 해제되고 테마는 light로 시작합니다. 테마 Effect는 body의 className 전체를 덮어쓰므로, 다른 body 클래스와 함께 사용할 때는 클래스 소유 범위도 조정해야 합니다.