상태 관리의 완성 - Context API와 Reducer의 만남
리액트 공식문서 기반의 리액트 입문기
들어가며
이전 편들에서 우리는 컴포넌트 내부의 복잡한 상태 로직을 useReducer Hook으로 효과적으로 관리하는 방법(4-3, 4-4편)과, 컴포넌트 트리의 깊이에 상관없이 데이터를 손쉽게 공유할 수 있는 Context API의 기본 개념(4-5편)을 살펴보았습니다. 이 두 가지 강력한 도구는 각각의 장점을 가지고 있지만, 함께 사용될 때 더욱 시너지를 발휘하여 복잡하고 확장 가능한 전역 상태 관리 시스템을 구축하는 데 도움을 줄 수 있습니다.
이번 편에서는 Context API와 useReducer Hook을 결합하여, 애플리케이션의 전역 상태와 그 상태를 변경하는 로직을 더욱 체계적이고 예측 가능한 방식으로 관리하는 방법에 대해 깊이 있게 알아보려고 합니다. 이 패턴을 통해 우리는 리액트 애플리케이션의 상태 관리 복잡도를 효과적으로 낮추고, 대규모 애플리케이션에서도 유지보수가 용이한 코드를 작성하는 데 필요한 통찰을 얻을 수 있을 것입니다.
useReducer와 Context API 결합의 필요성
Context API는 데이터를 props 없이 컴포넌트 트리 깊숙이 전달하는 데 탁월하지만, 단독으로 사용될 경우 몇 가지 한계에 직면할 가능성이 있습니다. 예를 들어, Context로 전달하는 상태 값이 복잡한 로직에 의해 자주 업데이트되거나, 여러 액션에 따라 다양한 방식으로 상태가 변경되어야 할 때, useState만으로는 이러한 상태 변화 로직을 관리하기 어려울 수 있습니다. 이러한 상황에서는 Context의 value에 직접 상태 변경 함수들을 포함시켜야 하는데, 이는 코드의 가독성을 해치고 Provider 컴포넌트 (하위 컴포넌트들에게 Context 값을 제공하는 컴포넌트)의 로직을 복잡하게 만들 가능성이 있습니다.
이때 useReducer Hook이 훌륭한 보완책이 될 수 있습니다. useReducer는 상태 업데이트 로직을 reducer 함수 내에 중앙 집중화하여 관리하며, 여러 액션에 대한 상태 변화를 예측 가능한 방식으로 처리할 수 있도록 돕습니다. 따라서 Context API와 useReducer를 결합하면, Context는 상태와 dispatch 함수를 전역적으로 제공하는 통로 역할을 하고, useReducer는 복잡한 상태 변경 로직을 책임지는 역할을 수행하게 됩니다. 이처럼 역할이 명확히 분리되면서, 전역 상태의 데이터뿐만 아니라 상태를 변경하는 로직까지 체계적으로 관리할 수 있는 강력한 패턴을 구축할 수 있습니다.
복잡한 전역 상태를 위한 패턴 (Context API + Reducer)
Context API와 useReducer를 결합한 패턴은 다음과 같은 방식으로 구성될 수 있습니다.
reducer함수 정의: 가장 먼저 애플리케이션의 전역 상태를 어떻게 변경할지 정의하는reducer함수를 작성합니다. 이 함수는 현재 상태(state)와 특정 행동(action)을 인자로 받아, 새로운 상태를 반환하는 순수 함수여야 합니다.- Context 분리 (State Context & Dispatch Context): 효율적인 상태 관리를 위해, 상태 값(
state)을 위한Context와 상태 변경 함수(dispatch)를 위한Context를 각각 생성하는 것이 일반적입니다. 이렇게 분리하면,state만 사용하는 컴포넌트와dispatch함수만 사용하는 컴포넌트가 각각 필요한Context만 구독하여 불필요한 리렌더링을 줄이는 데 도움을 줄 수 있습니다. - 커스텀
Provider컴포넌트 생성:useReducerHook을 사용하여 전역 상태와dispatch함수를 생성하고, 이들을 각각의Context(React 19 이전 버전에서는Context.Provider)를 통해 애플리케이션의 모든 하위 컴포넌트에 제공하는 커스텀Provider컴포넌트를 작성합니다. 이 커스텀Provider는 애플리케이션의 최상단 또는 전역 상태가 필요한 컴포넌트 트리의 상단에 위치하여야 합니다. - Custom Hook을 통한 Context 값 사용:
useContextHook을 직접 사용하는 대신,useContext를 래핑한 커스텀 Hook(useTodosState,useTodosDispatch등)을 만들어 컴포넌트에서 Context 값을 더욱 편리하고 안전하게 사용할 수 있도록 할 수 있습니다. 이는 Context의 오용을 방지하고 코드의 일관성을 유지하는 데 도움이 됩니다.
이러한 구조를 통해, 전역 상태 관리에 대한 로직과 데이터 전달이 더욱 명확하고 분리되어, 복잡한 애플리케이션에서도 효과적인 상태 관리가 가능해집니다.
리액트로 Context API와 Reducer 결합 구현하기
할 일 목록(Todo List) 관리 기능을 Context API와 useReducer를 결합한 패턴으로 구현하는 예제를 통해 살펴보겠습니다. 이 예제는 할 일 항목의 추가, 삭제, 토글 기능을 포함합니다.
파일 구조
src/
├── TodosContext.js
├── TodoForm.jsx
├── TodoItem.jsx
├── TodoList.jsx
└── App.jsx코드 (JSX 버전)
TodosContext.js
import { createContext, useContext, useReducer } from 'react';
// 1. 상태(State)와 디스패치(Dispatch)를 위한 Context 생성
const TodosStateContext = createContext(null);
const TodosDispatchContext = createContext(null);
// 2. Reducer 함수 정의: 상태 변경 로직
function todosReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return [
...state,
{
id: Date.now(), // 고유 ID 생성 (간단한 예시)
text: action.text,
isCompleted: false,
},
];
case 'TOGGLE_TODO':
return state.map(todo =>
todo.id === action.id ? { ...todo, isCompleted: !todo.isCompleted } : todo
);
case 'REMOVE_TODO':
return state.filter(todo => todo.id !== action.id);
default:
throw new Error(`Unhandled action type: ${action.type}`);
}
}
// 3. Custom Provider 컴포넌트: useReducer로 상태 관리 후 Context 제공
export function TodosProvider({ children }) {
const [todos, dispatch] = useReducer(todosReducer, []); // 초기 상태는 빈 배열
return (
<TodosStateContext value={todos}>
{/* React 19부터는 <TodosDispatchContext value={dispatch}> 사용 */}
<TodosDispatchContext value={dispatch}>{children}</TodosDispatchContext>
</TodosStateContext>
);
}
// 4. Custom Hook: 상태(State) 값 사용
export function useTodosState() {
const context = useContext(TodosStateContext);
if (context === null) {
throw new Error('useTodosState must be used within a TodosProvider');
}
return context;
}
// 5. Custom Hook: 디스패치(Dispatch) 함수 사용
export function useTodosDispatch() {
const context = useContext(TodosDispatchContext);
if (context === null) {
throw new Error('useTodosDispatch must be used within a TodosProvider');
}
return context;
}TodosContext.js 파일에서는 TodosStateContext와 TodosDispatchContext 두 개의 Context를 생성하여 상태와 dispatch 함수를 분리했습니다. todosReducer 함수는 할 일 목록에 대한 상태 변경 로직을 정의하며, ADD_TODO, TOGGLE_TODO, REMOVE_TODO 액션을 처리합니다. TodosProvider 컴포넌트는 useReducer를 사용하여 todos 상태와 dispatch 함수를 생성하고, React 19에서 권장하는 방식인 <Context>를 직접 사용하여 이들을 하위 컴포넌트에 제공합니다. useTodosState와 useTodosDispatch 커스텀 훅은 컴포넌트에서 이 값들을 쉽게 사용할 수 있도록 돕습니다.
App.jsx
import React from 'react';
import { TodosProvider } from './TodosContext';
import TodoList from './TodoList';
import TodoForm from './TodoForm';
export default function App() {
return (
<TodosProvider>
<div className="todo-app-container">
<h1 className="todo-app-title">Todo List</h1>
<TodoForm />
<TodoList />
</div>
</TodosProvider>
);
}App.jsx에서는 TodosProvider로 애플리케이션의 핵심 부분(TodoForm과 TodoList)을 감싸, 이들 컴포넌트가 전역 할 일 목록 상태와 상태 변경 함수에 접근할 수 있도록 합니다.
TodoForm.jsx
import React, { useState } from 'react';
import { useTodosDispatch } from './TodosContext';
export default function TodoForm() {
const [text, setText] = useState('');
const dispatch = useTodosDispatch();
const handleSubmit = e => {
e.preventDefault();
if (!text.trim()) return;
dispatch({ type: 'ADD_TODO', text });
setText('');
};
return (
<form onSubmit={handleSubmit} className="todo-form">
<input
type="text"
value={text}
onChange={e => setText(e.target.value)}
placeholder="새로운 할 일을 추가하세요"
className="todo-input"
/>
<button type="submit" className="todo-add-button">
추가
</button>
</form>
);
}TodoForm.jsx 컴포넌트에서는 useTodosDispatch 훅을 사용하여 dispatch 함수를 가져옵니다. 사용자가 할 일을 입력하고 제출하면, ADD_TODO 액션을 dispatch하여 전역 상태를 업데이트합니다.
TodoList.jsx
import React from 'react';
import { useTodosState } from './TodosContext';
import TodoItem from './TodoItem';
export default function TodoList() {
const todos = useTodosState();
return (
<ul className="todo-list">
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}TodoItem.jsx
import React from 'react';
import { useTodosDispatch } from './TodosContext';
export default function TodoItem({ todo }) {
const dispatch = useTodosDispatch();
const handleToggle = () => {
dispatch({ type: 'TOGGLE_TODO', id: todo.id });
};
const handleRemove = () => {
dispatch({ type: 'REMOVE_TODO', id: todo.id });
};
return (
<li className={`todo-item ${todo.isCompleted ? 'completed' : ''}`}>
<input
type="checkbox"
checked={todo.isCompleted}
onChange={handleToggle}
className="todo-checkbox"
/>
<span className="todo-text">{todo.text}</span>
<button onClick={handleRemove} className="todo-remove-button">
삭제
</button>
</li>
);
}TodoItem.jsx 컴포넌트에서도 useTodosDispatch 훅을 사용하여 dispatch 함수를 가져옵니다. 할 일 항목의 완료 상태를 토글하거나 삭제할 때, 해당하는 액션(TOGGLE_TODO, REMOVE_TODO)을 dispatch하여 전역 상태를 변경합니다. 이처럼 Context와 useReducer를 함께 사용하면, 복잡한 전역 상태 관리 로직을 깔끔하게 분리하고, 필요한 컴포넌트에서만 상태와 액션에 접근할 수 있게 되어 코드의 유지보수성과 확장성을 높일 수 있습니다.
Context API + Reducer 방식의 특징
- 중앙 집중식 상태 로직 관리:
useReducer는 복잡한 상태 업데이트 로직을reducer함수 내에 중앙 집중적으로 정의할 수 있게 합니다. 이는 여러 컴포넌트에 흩어져 있던 상태 변경 로직을 한곳에 모아 관리함으로써 코드의 응집도를 높여줄 수 있습니다. - 예측 가능한 상태 변화:
reducer패턴은 상태 변화가 항상action객체에 의해 트리거되고,reducer함수에 정의된 대로만 발생하도록 강제합니다. 이는 상태 변화를 예측 가능하게 만들고, 애플리케이션의 디버깅 및 테스트를 용이하게 하는 데 도움을 줄 수 있습니다. - 관심사의 명확한 분리:
Context API는 데이터 전달 메커니즘을 담당하고,useReducer는 상태 변경 로직을 담당함으로써 각자의 관심사를 명확하게 분리합니다. 이러한 분리는 코드의 복잡성을 줄이고, 각 부분을 독립적으로 이해하고 수정하기 쉽게 만들어줄 수 있습니다. - 확장성과 유지보수성: 애플리케이션의 규모가 커지고 전역 상태가 더욱 복잡해지더라도,
Context API와useReducer의 결합 패턴은 일관된 상태 관리 방식을 제공하여 확장성과 유지보수성을 높여줄 수 있습니다. 새로운 기능을 추가하거나 기존 로직을 수정할 때, 관련 코드들을 찾아내고 변경하는 과정이 더욱 체계적이게 됩니다.
다음 표는 Context API를 단독으로 사용했을 때와 useReducer와 함께 사용했을 때의 주요 차이점을 요약한 것입니다.
| 특징/상황 | Context API 단독 사용 | Context API + useReducer 결합 |
|---|---|---|
| 상태 복잡성 | 단순하고 독립적인 상태, 적은 빈도의 업데이트에 적합 | 복잡하고 상호 연결된 상태, 잦은 업데이트, 복잡한 로직에 적합 |
| 상태 로직 | 컴포넌트 내 useState 및 이벤트 핸들러에 분산 | reducer 함수에 중앙 집중화되어 컴포넌트 외부에서 관리 |
| 코드 가독성 | 단순 상태에서는 좋으나, 복잡해지면 Provider 로직 복잡해짐 | 상태 로직이 reducer로 분리되어 컴포넌트 코드가 깔끔해짐 |
| 유지보수성 | 복잡한 상태에서 Provider 로직 변경 시 여러 곳 수정 필요 | reducer 함수만 수정하면 되므로 유지보수 용이 |
| 테스트 용이성 | Provider 컴포넌트 전체를 테스트해야 할 수 있음 | reducer 함수가 순수 함수이므로 독립적인 단위 테스트 용이 |
| 주요 사용처 | 전역 테마, 사용자 인증 정보 등 단순하고 정적인 데이터 | Todo List, 장바구니, 복잡한 폼 관리 등 동적이고 복잡한 전역 상태 관리 |
요약
이번 편에서는 리액트의 Context API와 useReducer Hook을 결합하여 복잡한 전역 상태를 효과적으로 관리하는 패턴에 대해 알아보았습니다. 이 강력한 조합은 Props Drilling 문제를 해결하고, 상태 변경 로직을 중앙 집중화하며, 코드의 예측 가능성과 유지보수성을 크게 향상하는 데 도움을 줄 수 있습니다. 특히, Todo List 예제를 통해 실제 코드가 어떻게 작동하는지 살펴보면서, 이 패턴이 대규모 애플리케이션의 상태 관리에 얼마나 유용하게 활용될 수 있는지 이해하는 시간을 가졌습니다.
- 결합 필요성:
Context의 데이터 전달 능력과useReducer의 상태 로직 관리 능력을 결합하여 복잡한 전역 상태 문제 해결 - 주요 구성:
reducer함수,State Context와Dispatch Context분리, 커스텀Provider컴포넌트, 커스텀 Hook을 통한 값 사용 - 장점: 중앙 집중식 상태 로직 관리, 예측 가능한 상태 변화, 관심사의 명확한 분리, 확장성 및 유지보수성 향상
참고문서
– [Reducer와 Context로 확장하기 (Scaling Up with Reducer and Context)]