테마 변경 기능 구현
useReducer와 Context를 결합해 로그인·로그아웃과 테마 전환 상태를 여러 화면에서 일관되게 관리합니다.
7장에서 배운 useState와 useReducer, Context API를 모아, AppProvider가 상태를 가지고 하위 화면들이 같은 값을 읽는 구조를 구현합니다. 이 실습은 액션과 순수한 리듀서로 변경의 이유를 나타내고, useReducer의 state와 dispatch를 Context로 전달합니다. 실제 Redux의 외부 store나 selector마다의 구독 비교, 미들웨어와 DevTools를 구현한 것은 아닙니다. 대신 작거나 중간 규모의 애플리케이션에서 복잡한 외부 라이브러리 없이도 여러 화면이 함께 쓰는 상태를 관리하는 방법을 익힐 수 있습니다.
실습 목표: 인증/테마 전역 상태 운용
이 실습은 실무에서 생각하는 방식으로 진행합니다. 인증 상태가 바뀌는 시점, 곧 로그인 직후와 로그아웃 직후, 보호된 경로에 직접 들어올 때 어떤 버그가 생길 수 있는지 먼저 가정하고 구조를 설계합니다.
이를 위해 앱 전반에서 함께 쓸 상태와 dispatch 함수를 위한 Context를 만들고, 복잡한 갱신 로직을 처리할 리듀서 함수를 작성합니다. 그다음 useReducer로 상태와 dispatch를 만들어 Context Provider로 하위 컴포넌트에 제공하는 감싸개 컴포넌트를 만들고, 하위 컴포넌트에서는 useContext로 그 상태와 dispatch를 씁니다. 예시로는 사용자의 로그인 상태 관리와 테마 변경을 함께 구현합니다.
시나리오: 사용자 로그인 및 테마 관리
만들 애플리케이션은 세 가지 기능을 가집니다. 사용자의 로그인 상태(isLoggedIn, user)를 앱 전체에서 관리해 로그인과 로그아웃을 할 수 있고, 다크 모드와 라이트 모드의 테마를 앱 전체에서 바꿀 수 있으며, 로그인 상태와 테마에 따라 UI가 바뀌는 것을 확인할 수 있습니다.
준비 단계: 전역 상태 범위 정하기
준비 단계에서 나누는 기준은 분명합니다. 여러 화면에서 되풀이되는 인증과 테마의 상태는 하나의 Context로 모으고, 화면마다 잠깐 쓰는 상태는 그 화면의 지역 상태로 남겨 불필요하게 전역으로 올리지 않습니다.
Vite로 만든 프로젝트가 있다고 가정하고, src 폴더에 다음 구조로 파일을 만듭니다. JSX가 들어 있는 파일은 모두 .jsx로 저장하며, 아래 코드의 제목도 이 이름을 따릅니다.
Context 예제용 전역 스타일 정리 (index.css)
기본 스타일과 테마별 CSS 변수(사용자 지정 속성)를 정의합니다. 앞부분의 body, #root, .button 규칙은 6장 블로그 실습의 전역 스타일과 같습니다. 달라진 곳은 .main-content와 제목의 색을 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의 테마 클래스(light-theme, dark-theme)에서 CSS 사용자 지정 속성을 정의하고, 각 요소는 var(--변수 이름, 기본값)으로 그 값을 읽습니다. 그래서 body의 클래스를 바꾸면, 그 변수를 쓰는 요소의 색이 함께 바뀝니다.
전역 상태 Context 및 Reducer 정의
이 파일에서 Context와 리듀서, 그리고 둘을 잇는 Provider 컴포넌트를 만듭니다. 아래 코드는 주석의 번호 순서대로 초기 상태, 리듀서, Context, Provider를 정의합니다. 인증 정보(isLoggedIn, user)와 테마(theme)를 한 state 객체에 함께 두고, 세 가지 액션으로만 바꿉니다.
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>
);
};테마의 변경은 리듀서에서 theme 값만 바꾸는 것으로 끝나지 않습니다. 바뀐 상태가 useEffect를 거쳐 body의 클래스가 되고, CSS 변수가 실제 화면의 색으로 적용되는 경로까지 함께 봐야 합니다.
테마 토글이 Context와 Effect를 거쳐 CSS에 반영된다 — 상태 추적
TOGGLE_THEME의 light→dark 뒤 Context 렌더와 commit 후 body Effect/CSS 변수 적용은 다른 시점과 경로다. 같은 theme/DOM class/화면 값을 추적해 consumer가 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 = #222header는 var(--header-bg)를 읽습니다. body의 기본 background-color는 #f4f7f6으로 고정되어 있으므로 모든 body 색상이 자동으로 바뀐다고 해석하지 않습니다.
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-theme | Provider의 state와 body의 light 표시가 일치한다 | 렌더와 외부 DOM의 연결은 서로 다른 경로다 |
| toggle 뒤 render | state.theme: dark / consumer: dark | reducer의 dark를 Context 소비자가 새 value로 읽는다 | 소비자가 Effect를 직접 호출하는 흐름은 아니다 |
| commit 뒤 Effect | body: 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를 호출하는 오해를 줄인다.
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 = #222header는 var(--header-bg)를 읽습니다. body의 기본 background-color는 #f4f7f6으로 고정되어 있으므로 모든 body 색상이 자동으로 바뀐다고 해석하지 않습니다.
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-theme | Provider의 state와 body의 light 표시가 일치한다 | 렌더와 외부 DOM의 연결은 서로 다른 경로다 |
| toggle 뒤 render | state.theme: dark / consumer: dark | reducer의 dark를 Context 소비자가 새 value로 읽는다 | 소비자가 Effect를 직접 호출하는 흐름은 아니다 |
| commit 뒤 Effect | body: dark-theme / CSS: 변수 사용 요소 갱신 | theme Effect가 body class를 바꾸고 var 사용 요소에 색을 적용한다 | state 변경과 body 표현의 시점을 분리한다 |
위 그림은 테마 state가 Context 소비자의 렌더링과 Effect라는 두 경로로 화면에 반영되는 과정을 보여 줍니다. Header가 TOGGLE_THEME을 dispatch하면 순수한 appReducer가 theme을 light에서 dark로 바꾼 새 state를 돌려주고, AppProvider가 그 state로 렌더링합니다. 여기서 두 경로가 갈라집니다. 한쪽에서는 Context의 새 value를 읽는 Header와 ThemeChanger 같은 소비자가 state.theme을 읽어 테마 문구와 버튼, 조건부 색을 렌더링하고, 다른 쪽에서는 커밋한 뒤 [state.theme]이 바뀐 Effect가 body.className을 dark-theme으로 덮어씁니다. 그러면 body.dark-theme의 변수들이 상속되어 var()를 쓰는 헤더와 카드, 본문의 색에 반영됩니다. 그림의 설명처럼 소비자가 Effect를 호출하는 흐름이 아니라, 같은 새 state에서 두 경로가 나란히 이어지는 것입니다.
그림 위쪽의 예처럼 light에서 dark로 바꾸면 state.theme이 dark가 되고, 소비자는 dark를 읽으며, Effect는 body.className을 dark-theme으로 바꾸고, CSS에서는 --header-bg가 #222가 됩니다. 헤더는 var(--header-bg)를 읽으므로 어두워지지만, body 자체의 기본 배경색은 #f4f7f6으로 고정되어 있으므로 모든 배경이 저절로 어두워진다고 해석하지 않습니다. 그림 아래 칸들이 덧붙이듯 이 실습은 저장소에서의 복원과 시스템 테마의 선택을 구현하지 않아 새로고침하면 로그아웃된 light 상태로 다시 시작하고, 인증과 테마를 { state, dispatch } 객체 하나로 제공하므로 로그인의 변경도 그 Context의 다른 소비자에게 새 value를 전달합니다. 또 Effect가 body.className 전체를 덮어쓰므로 다른 body 클래스와 함께 쓰려면 이 코드가 다룰 클래스의 범위를 조정해야 하며, 예제의 로그인은 고정된 사용자의 UI일 뿐이라서 클라이언트의 이동이 서버의 권한 검증을 대신하지 않습니다.
컴포넌트
Header.jsx
로그인·로그아웃 버튼과 테마 전환 버튼을 담습니다. Header는 useContext(AppContext)로 받은 state에서 세 값을 꺼내 화면을 정하고, 버튼을 누르면 dispatch로 액션만 보냅니다. 로그인 버튼은 실제 인증 없이 고정된 mockUser를 payload에 실어 보냅니다.
import React, { useContext } from 'react';
import { AppContext } from '../contexts/AppContext'; // AppContext 임포트
import { Link } from 'react-router';
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;헤더의 배경과 글자색은 var(--header-bg)처럼 CSS 변수로 지정했으므로, Header가 테마 값으로 색을 계산하지 않아도 body의 테마 클래스가 바뀌면 함께 바뀝니다. 반면 테마 버튼의 글자는 theme 값을 직접 읽어 정합니다.
UserProfile.jsx
로그인한 사용자의 정보를 보여 줍니다. UserProfile은 Context에서 값을 읽기만 하고 dispatch는 쓰지 않으며, 카드의 배경과 테두리는 var(--card-bg)와 var(--card-border)로 지정해 테마를 따릅니다.
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;isLoggedIn이 false이면 안내 문구를 먼저 돌려주므로, 그 아래에서 user.name을 읽는 시점에는 user가 null이 아닙니다.
ThemeChanger.jsx
테마에 따라 스타일이 바뀌는 간단한 예시 컴포넌트입니다. 이 컴포넌트는 두 가지 방식으로 테마를 따릅니다. 카드의 배경과 테두리는 CSS 변수를 읽고, 테마 이름을 강조하는 글자색은 theme 값으로 직접 고릅니다.
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;두 방식 모두 같은 state.theme에서 출발하지만 반영되는 경로가 다릅니다. 직접 고른 글자색은 이 컴포넌트가 다시 렌더링될 때 바뀌고, CSS 변수를 읽는 배경은 앞의 Effect가 body의 클래스를 바꾼 뒤에 바뀝니다.
화면별 컨텍스트 적용 페이지
HomePage.jsx
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;홈 페이지는 두 컴포넌트를 배치하기만 하고 Context를 직접 읽지 않습니다. 로그인 여부나 테마가 바뀌면 그 값을 읽는 UserProfile과 ThemeChanger가 각자 새 값으로 다시 렌더링됩니다.
DashboardPage.jsx
import React, { useContext } from 'react';
import { AppContext } from '../contexts/AppContext';
import { useNavigate } from 'react-router';
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;DashboardPage는 로그인하지 않았다면 먼저 null을 렌더링해 사용자의 내용을 숨기고, Effect에서 알림을 띄운 뒤 홈으로 이동합니다. 개발 모드의 Strict Mode에서는 Effect가 한 번 더 실행되어 알림이 두 번 뜰 수 있으므로, 알림의 횟수를 인증의 성공이나 실패를 판단하는 기준으로 삼지 않습니다.
NotFoundPage.jsx
최종 App이 가져오는 NotFoundPage는 6장의 댓글·작성·fallback 라우트에서 만든 화면을 다시 써도 되고, 파일이 없다면 다음의 최소한의 화면을 준비합니다. 이 화면은 상태도 Context도 쓰지 않고, 홈으로 돌아가는 Link 하나만 보여 줍니다.
import { Link } from 'react-router';
export default function NotFoundPage() {
return (
<section>
<h2>페이지를 찾을 수 없습니다.</h2>
<Link to="/">홈으로 이동</Link>
</section>
);
}App.jsx와 main.jsx (최종 설정)
AppProvider로 앱 전체를 감싸고 라우팅을 설정합니다. 이 실습은 6장과 같은 React Router 8의 react-router 패키지를 씁니다.
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router';
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'; // 6장 실습에서 만든 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;App 안에 BrowserRouter가 이미 있으므로 진입 파일에서 라우터를 다시 감싸지 않습니다. Vite의 index.html에 있는 id="root" 요소와 다음 진입 파일을 잇고, 기본 데모용 App.css 대신 이 실습의 index.css만 먼저 적용합니다.
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>,
);검증 순서 및 확인 사항: 보호 라우트 회귀 점검
실습의 검증은 로그인하지 않은 접근의 차단, 로그인한 뒤의 허용, 로그아웃한 뒤의 다시 차단 순서로 고정합니다. 이 흐름으로 시험하면 보호된 경로에서 생기는 회귀를 빨리 알아챌 수 있습니다.
먼저 프로젝트를 준비합니다. npm create vite@latest context-reducer-app -- --template react --no-interactive로 프로젝트를 만들고, cd context-reducer-app으로 이동해 npm install react-router로 라우터를 설치합니다. --no-interactive가 없으면 린터의 종류와 바로 설치하고 실행할지를 묻는 질문이 나오고, 실행을 고르면 개발 서버가 그 터미널을 차지합니다. 위의 구조대로 파일을 만들어 코드를 붙여 넣고(특히 index.css를 잊지 마세요), npm run dev로 개발 서버를 띄워 터미널에 나온 주소를 엽니다.
그다음 기능을 시험합니다. 헤더의 “다크 모드”와 “라이트 모드” 버튼을 눌러 헤더와 본문, 카드의 배경색과 글자색이 바뀌는지 확인합니다. 처음 상태에서 로그인 버튼을 누르면 “ReactUser님, 환영합니다!” 알림 뒤에 헤더에 “환영합니다, ReactUser!”가 나타나고, 홈의 사용자 프로필에 이름과 이메일이 보여야 합니다. 로그아웃 버튼을 누르면 로그인하지 않은 상태로 돌아가야 합니다.
대시보드도 확인합니다. 로그인하지 않은 상태에서 헤더의 대시보드 링크를 누르거나 주소창에 /dashboard를 직접 입력하면, “로그인이 필요한 페이지입니다!” 알림 뒤에 홈으로 이동해야 합니다. 로그인한 상태에서 대시보드로 이동하면 “ReactUser님의 대시보드”가 보여야 하고, /nonexistent-page처럼 없는 URL로 들어가면 찾을 수 없음 화면이 보여야 합니다. 이 실습의 로그인은 고정된 사용자로 UI의 상태를 바꾸는 모형이며, 대시보드의 클라이언트 이동은 서버 데이터에 대한 접근 권한을 대신하지 않습니다.
두 상태 필드와 화면의 결과를 함께 따라가 봅니다. 처음에는 isLoggedIn이 false, user가 null, theme이 'light'입니다. 로그인 버튼은 고정된 ReactUser 객체를 LOGIN의 페이로드로 넘기므로, 다음 상태에서는 isLoggedIn이 true가 되고 user가 함께 바뀝니다. 프로필과 헤더가 같은 사용자 정보를 읽는 이유입니다. TOGGLE_THEME은 인증의 두 필드를 그대로 두고 테마만 바꾸며, LOGOUT은 사용자의 두 필드를 비우되 테마는 그대로 둡니다.
테마를 dark로 바꾸면 ThemeChanger의 문구와 다음 모드 버튼은 렌더의 새 값을 보여 주고, 커밋한 뒤 Effect가 body.dark-theme을 적용하면 CSS 변수를 읽는 헤더와 카드, 본문의 색이 바뀝니다. 직접 URL을 입력하거나 새로고침할 때의 첫 문서 응답은 호스트가 맡으므로, 배포할 때는 앱 경로의 SPA 대체 응답과 서버의 실제 권한 검증을 따로 준비합니다.
좁은 화면의 버튼도 같은 상태를 바꾸게 하기
헤더의 두 Flexbox 묶음은 로그인 문구와 링크, 버튼을 한 줄에 놓습니다. 실습의 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를 고친다고 리듀서의 입력 조건이 바뀌지는 않습니다.
정리
useReducer로 인증과 테마의 전이 규칙을 리듀서 하나에 모으고, Context로state와dispatch를 여러 화면에 전달했습니다.AppProvider가 상태의 소유자이며, Context는 그 값을 하위 트리에 전달하는 통로일 뿐 외부 Redux store가 아닙니다.- 테마 state는 Context 소비자의 렌더링과, 커밋한 뒤
body클래스를 바꾸는 Effect라는 두 경로로 화면에 반영됩니다. - 이 실습에는 상태의 저장과 복원이 없어 새로고침하면 로그아웃된
light상태로 시작하며, 클라이언트의 이동은 서버의 권한 검증을 대신하지 않습니다. - 더 큰 프로젝트나 특정한 요구가 있다면 Redux Toolkit, Zustand, Jotai 같은 상태 관리 라이브러리를 검토할 수 있습니다.
연습 문제
1. 순서가 달라도 남는 필드
처음 화면에서 다크 모드 전환, 로그인, 대시보드 이동, 로그아웃을 차례로 실행합니다. 각 단계의 theme, isLoggedIn, user와 로그아웃 직후 대시보드 본문의 결과를 적어 보세요.
해설 보기
다크 모드로 바꾼 뒤에는 dark, false, null이고, 로그인한 뒤에는 dark, true, ReactUser입니다. 대시보드로의 이동은 상태를 바꾸는 액션이 아니므로 세 필드를 그대로 두고 로그인한 사용자의 화면을 보여 줍니다.
로그아웃한 뒤에는 dark, false, null입니다. 대시보드 본문은 null을 돌려줘 사용자의 내용을 숨기고, Effect가 알림을 띄운 뒤 홈으로 이동합니다. LOGOUT 분기에는 theme을 초기화하는 규칙이 없으므로 테마는 dark로 남습니다.
2. 새로고침과 찾을 수 없음 화면
1번의 마지막 상태에서 새로고침하면 세 필드는 어떤 값으로 시작할까요? 클라이언트의 찾을 수 없음 화면이 보인다는 사실로 서버의 HTTP 404나 접근 권한까지 확인할 수 있을까요?
해설 보기
저장하고 복원하는 코드가 없으므로 새로고침하면 초기값인 light, false, null로 시작합니다.
호스트가 앱 문서를 돌려줬을 때만 클라이언트 라우트를 고를 수 있으며, 와일드카드 UI는 응답의 HTTP 상태를 바꾸지 않습니다. 또 이 모형의 로그인이나 클라이언트 이동은 서버 API의 접근 권한을 검증하지 않으므로, 둘 다 화면만으로 확인할 수 없습니다.
이것으로 7장을 마칩니다. 7장에서는 상태의 소유자와 공유 범위를 정하는 기준에서 시작해, Context로 값을 멀리 전달하는 방법, useReducer로 전이 규칙을 모으는 방법, Redux와 Redux Toolkit의 기초 개념을 익히고, Context와 useReducer를 모아 로그인과 테마를 함께 관리하는 기능을 구현했습니다. 다음 장의 자바스크립트의 비동기 처리부터는 비동기 처리와 데이터 요청을 다루며, useEffect로 데이터를 가져오고 로딩과 오류를 처리하며 axios로 게시판을 만드는 방법을 익힙니다.