Context API + useReducer로 flux 패턴 구현하기
Redux 없이 단방향 데이터 흐름 만들기
서론
React에서 여러 컴포넌트가 공유하는 상태를 관리할 때 prop-drilling 문제에 직면한다. 부모에서 자식으로, 또 그 자식으로 props를 계속 전달하다 보면 코드가 복잡해지고 유지보수가 어려워진다.
Flux 패턴은 이런 상태 관리를 체계적으로 수행하기 위한 아키텍처다. 데이터 흐름을 단방향으로 유지해서 상태 변경을 예측 가능하게 만든다. Redux가 Flux 패턴을 기반으로 만들어졌지만, 추가 라이브러리 없이 React의 Context API와 useReducer만으로도 같은 패턴을 구현할 수 있다.
본 글에서는 Context API와 useReducer를 활용해 Flux 패턴을 구현하는 방법을 다룬다.
왜 Flux 패턴인가
과거 Backbone 같은 프레임워크는 이벤트 기반으로 상태를 관리했다. Model이 변경되면 이벤트를 발생시키고, 여러 View가 이 이벤트를 구독해서 반응하는 구조다.
문제는 이벤트가 연쇄적으로 발생할 때다. View A가 Model을 변경하면, 그게 View B를 업데이트하고, View B가 다시 다른 Model을 변경하고, 이게 또 View C를 업데이트한다. 상태 변경의 흐름을 추적하기 어렵고, 버그가 발생했을 때 원인을 찾기 힘들다.
Flux는 단방향 데이터 흐름으로 이 문제를 해결한다. 상태를 변경하는 방법이 하나로 제한되고, 그 과정을 추적할 수 있다.
Flux 패턴의 이해
Flux 패턴은 4가지 요소로 구성된다.
Action
애플리케이션에서 발생하는 이벤트나 사용자 상호작용을 표현한다. 보통 type과 payload를 포함한다. 그림에서 배달부로 표현되듯이, 액션은 정보를 담아서 Store로 전달하는 택배 상자 같은 역할을 한다.
// 카운터 증가
{ type: 'INCREMENT', payload: 1 }
// 사용자 로그인
{ type: 'USER_LOGIN', payload: { userId: 123, userName: 'john' } }
// 할일 추가
{ type: 'ADD_TODO', payload: { id: 1, text: '장보기', completed: false } }
// 데이터 로딩 시작
{ type: 'FETCH_DATA_START' }
type은 무슨 일이 일어났는지 알려주고, payload는 필요한 데이터를 전달한다. payload가 필요 없는 액션도 있다.
Dispatcher
액션을 받아서 Store로 전달하는 중앙 허브다. 모든 액션이 Dispatcher를 거치기 때문에 상태 변경을 일관되게 처리할 수 있다. 그림에서 카운터 직원으로 표현되듯이, 배달부(액션)가 가져온 택배를 확인하고 처리 담당자(reducer)에게 넘겨주는 관문 역할을 한다.
// 컴포넌트에서 사용
const handleClick = () => {
dispatch({ type: 'INCREMENT', payload: 1 });
};
// 여러 액션을 순차적으로 dispatch
const handleLogin = async (credentials) => {
dispatch({ type: 'LOGIN_START' });
try {
const user = await loginAPI(credentials);
dispatch({ type: 'LOGIN_SUCCESS', payload: user });
} catch (error) {
dispatch({ type: 'LOGIN_FAILURE', payload: error.message });
}
};
dispatch 함수는 동기적으로 동작한다. 액션을 받으면 즉시 reducer를 호출해서 상태를 업데이트한다.
Store
애플리케이션의 상태와 상태 변경 로직을 관리한다. Dispatcher로부터 액션을 받으면 상태를 업데이트하고, 구독자에게 변경을 알린다. 상태를 직접 수정하지 않고 항상 새로운 객체를 반환한다.
그림에서 Store는 여러 요소를 포함하는 큰 상자로 표현된다.
state(책): 현재 상태 데이터를 기록reducer(펜으로 쓰는 사람): 액션을 보고 새로운 상태를 작성dispatch(카운터): 액션을 받아서 reducer로 전달getState(카운터): 컴포넌트에게 현재 state를 제공subscribe(카운터): 상태 변경을 구독하는 컴포넌트 등록
// 현재 상태
const currentState = {
count: 5,
user: null,
todos: []
};
// INCREMENT 액션을 받으면
const action = { type: 'INCREMENT', payload: 1 };
// reducer가 새 상태를 반환
const newState = {
count: 6, // 변경됨
user: null, // 유지
todos: [] // 유지
};
// currentState !== newState (다른 객체)
// currentState.user === newState.user (같은 참조)
변경되지 않은 부분은 같은 참조를 유지한다. 이를 통해 React는 어떤 부분이 바뀌었는지 확인할 수 있다.
View
사용자 인터페이스를 구성하고 Store의 상태를 화면에 표시한다. 사용자 상호작용을 액션으로 변환해서 Dispatcher로 보낸다. React 컴포넌트가 View 역할을 한다. 그림에서 render(삽으로 땅 파는 사람)로 표현되듯이, Store의 state를 받아서 실제 UI를 구축하는 작업자 역할을 한다.
const TodoList = () => {
// Store에서 상태 읽기
const { state, dispatch } = useContext(AppContext);
// 사용자 상호작용을 액션으로 변환
const handleAddTodo = (text) => {
dispatch({
type: 'ADD_TODO',
payload: { id: Date.now(), text, completed: false }
});
};
const handleToggleTodo = (id) => {
dispatch({ type: 'TOGGLE_TODO', payload: id });
};
// Store의 상태를 화면에 표시
return (
<div>
<h1>할 일: {state.todos.length}개</h1>
<ul>
{state.todos.map(todo => (
<li key={todo.id} onClick={() => handleToggleTodo(todo.id)}>
{todo.text}
</li>
))}
</ul>
</div>
);
};
View는 상태를 직접 변경하지 않는다. 항상 dispatch를 통해 액션을 보내고, Store가 상태를 업데이트하면 자동으로 리렌더링된다.
Context API와 useReducer로 구현하기
Context API는 컴포넌트 트리 전체에 데이터를 전달하는 방법을 제공한다. useReducer는 복잡한 상태 로직을 관리하는 훅이다. 이 둘을 합치면 Flux 패턴을 구현할 수 있다.
프로젝트 구조
project-root/
├── src/
│ ├── components/
│ │ ├── Counter.js
│ ├── context/
│ │ ├── AppContext.js
│ ├── reducers/
│ │ ├── appReducer.js
│ ├── actions/
│ │ ├── actionTypes.js
│ │ ├── actions.js
│ ├── App.js
│ ├── index.js
1. Reducer 작성 (Store)
reducers/appReducer.js 파일을 만든다. Reducer는 현재 상태와 액션을 받아서 새로운 상태를 반환하는 함수다.
export const initialState = {
count: 0,
};
export const appReducer = (state, action) => {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 };
case 'DECREMENT':
return { ...state, count: state.count - 1 };
default:
return state;
}
};
action.type을 확인해서 어떤 상태 변경을 수행할지 결정한다. { ...state }로 기존 상태를 복사하고, 변경할 부분만 덮어쓴다. 원본 상태를 직접 수정하지 않는 것이 핵심이다.
state.count += 1 같은 직접 수정은 React가 변경을 감지하지 못한다. 항상 새 객체를 반환해야 한다. Reducer는 순수 함수여야 한다. fetch 같은 비동기 로직은 컴포넌트에서 처리하고, reducer는 결과 데이터만 받아서 상태를 업데이트한다.
2. Action 정의
actions/actionTypes.js에 액션 타입을 상수로 정의한다.
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
문자열을 직접 쓰지 않고 상수로 정의하는 이유는 두 가지다. 첫째, 오타를 방지한다. 'INCREMNET' 같은 실수를 컴파일 단계에서 잡을 수 있다. 둘째, 나중에 액션 타입을 변경할 때 한 곳만 수정하면 된다.
actions/actions.js에 액션 생성자 함수를 만든다.
import { INCREMENT, DECREMENT } from './actionTypes';
export const increment = () => ({
type: INCREMENT,
});
export const decrement = () => ({
type: DECREMENT,
});
컴포넌트에서 dispatch({ type: 'INCREMENT' }) 대신 dispatch(increment())를 사용할 수 있다. 나중에 payload를 추가하기도 쉽다.
export const increment = (amount = 1) => ({
type: INCREMENT,
payload: amount,
});
3. Context 생성 (Dispatcher + Store 연결)
context/AppContext.js에서 Context를 만들고 Provider를 정의한다.
import { createContext, useReducer } from 'react';
import { appReducer, initialState } from '../reducers/appReducer';
const AppContext = createContext();
const AppProvider = ({ children }) => {
const [state, dispatch] = useReducer(appReducer, initialState);
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
);
};
export { AppContext, AppProvider };
useReducer는 [state, dispatch]를 반환한다. state는 현재 상태고, dispatch는 액션을 보내는 함수다. 이 둘을 Context로 제공하면 하위 컴포넌트 어디서든 접근할 수 있다.
4. Component에서 사용 (View)
components/Counter.js에서 Context를 사용한다.
import { useContext } from 'react';
import { AppContext } from '../context/AppContext';
import { increment, decrement } from '../actions/actions';
const Counter = () => {
const { state, dispatch } = useContext(AppContext);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch(increment())}>+</button>
<button onClick={() => dispatch(decrement())}>-</button>
</div>
);
};
export default Counter;
useContext(AppContext)로 state와 dispatch를 가져온다. 버튼을 클릭하면 dispatch로 액션을 보낸다.
dispatch는 액션 객체를 받는다. dispatch(INCREMENT) 같이 타입만 전달하면 reducer에서 action.type이 undefined가 된다. dispatch({ type: INCREMENT }) 또는 dispatch(increment())를 사용한다.
5. Provider 적용
App.js에서 AppProvider로 컴포넌트를 감싼다.
import React from 'react';
import { AppProvider } from './context/AppContext';
import Counter from './components/Counter';
const App = () => (
<AppProvider>
<Counter />
</AppProvider>
);
export default App;
index.js에서 앱을 렌더링한다.
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
전체 동작 흐름
사용자가 + 버튼을 클릭하면 다음 과정이 실행된다.
각 요소의 세부 동작을 보면 다음과 같다.
- View에서 사용자가 버튼 클릭
- Action에서
increment()가{ type: 'INCREMENT' }액션 객체 반환 - Dispatcher에서
dispatch가 액션을appReducer로 전달 - Reducer에서
action.type을 확인하고{ ...state, count: state.count + 1 }반환 - Store에서
useReducer가 새 상태로 업데이트하고 Context 값 변경 - View에서 Counter 컴포넌트가 리렌더링되어 새 count 값 표시
이 흐름은 계속 반복된다. 사용자가 다시 버튼을 클릭하면 같은 순환이 다시 시작된다.
마무리
이 글은 "전역 상태 관리 라이브러리를 꼭 사용해야 할까?"라는 의문에서 시작했다. Redux, Zustand, Recoil 같은 라이브러리를 추가하기 전에, React 자체 기능만으로 충분한지 알고 싶었다.
검색 결과 Context API와 useReducer를 조합하면 Flux 패턴을 구현할 수 있었다. Action, Dispatcher, Store, View로 이어지는 단방향 데이터 흐름을 React 내장 기능만으로 만들 수 있다. Redux가 제공하는 핵심 패턴을 추가 라이브러리 없이 구현 가능하다는 점이 고무적이다.
물론 Redux를 완벽하게 대체할 수 있는지는 이 글에서 깊이 있게 검증하지 않았다. Redux DevTools, 미들웨어 생태계, 성능 최적화 같은 부분에서는 여전히 Redux가 우위에 있을 수 있다. 하지만 중소규모 프로젝트에서 단순히 상태 관리 패턴이 필요하다면, Context API와 useReducer로 충분히 해결할 수 있다는 걸 확인했다.
references
https://velog.io/@j7papa/Context-API-useReducer