발전된 상태 관리의 필요성
오늘 이 내용은 상태 관리에서 useState 훅만 쓰면 되지 않느냐에서 출발합니다.
예를 들어, 상품 장바구니 기능을 구현할 때 useState만을 사용할 경우, State를 하나하나씩 나눠야 합니다.
const [items, setItems] = useState([]);
const [selectedIds, setSelectIds] = useState([]);
const [sortBy, setSortBy] = useState("price");
const [totalPrice, setTotalPrice] = useState(0);
const [discount, setDiscount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);이 부분이 복잡하다고 생각할 수도 있지만, 이 정도는 나눌 수도 있다고 생각할 수도 있습니다.
진짜 문제는 다음에서 발생합니다.
예를 들어, 상품 추가하기 버튼을 눌렀을 때 상품을 배열에 추가함과 동시에 총 금액도 다시 계산해야 합니다.
2개의 State를 변경해줘야 합니다.
function handleAddToCart(newItem) {
setItems((prev) => [...prev, newItem]);
setTotalPrice(prev => prev + newItem.price);
}진짜 문제는 단순히 2개의 State를 변경하는 것 뿐만 아니라 나중에 상품 삭제 기능을 구현할 때
토탈 금액까지 업데이트해야 해서 코드가 복잡해져 유지보수 측면에서 어려움이 생기는 것은 피할 수 없습니다.
function handleRemoveItem(id) {
setItems((prevItems) => {
const filtered = prevItems.filter(item => item.id != id);
const newTotal = filtered.reduce((sum, item) => sum+ item.price, 0)
setTotalPrice(newTotal)
return filtered
})
}이 경우는 2개의 액션이 동시에 발생한 경우이지만, 동시에 5개의 액션이 발생했던 경우라면 어떠할까요?
즉 하나의 이벤트가 발생했을 때 무엇이 어떻게 바뀌는지 한눈에 파악하기 어렵습니다.
이것이 로직의 파편화와 상태 동기화의 불편함의 핵심입니다.
위에서는 장바구니 리스트를 삭제할 때 총 금액만 영향을 주게 했지만,
실제로는 정렬 기준이나 할인 금액에도 영향을 줄 수 있어 유지보수가 더 힘들어질 수 있습니다.
React는 이러한 한계를 극복하기 위해 useReducer라는 도구를 준비했습니다.
useReducer
useReducer는 상태를 여러 조각으로 나누어 관리하는 방식이 아니라, 하나의 큰 상태 덩어리로 보는 방식입니다.
그 상태가 특정 액션에 따라 어떻게 바뀌는지 명확하게 정의하는 방식입니다.

우리는 이 방식을 통해 관심사의 분리라는 중요한 개념을 실천합니다.
컴포넌트는 이제 화면을 그리는데에만 집중하고, 상태를 바꾸는 복잡한 비즈니스 로직은 별도의 규칙성에 맞깁니다.
이제 **행동(Action)**과 **상태 변화 규칙(Rules)**를 한 곳에 모아두는 것이 훨씬 관리하기 편해질 것입니다.
이렇게 하면 React의 원칙 중 하나인 예측 가능한 상태관리가 가능해집니다.
여기서 useReducer에 들어가는 핵심요소에 대해 알아봅시다.
액션(Action)
사용자가 어떤 행동을 했는가를 뜻합니다. 지금 어떤 변화가 일어나야 하는지를 설명하는 이름입니다.
액션(Action) 객체 안에는 보통 type과 payload가 함께 담기는데
type은 하는 행동의 정의에 해당하고, payload는 어떤 데이터에 해당하는지 객체로 받아줍니다.

리듀서(Reducer)
리듀서는 상태를 바탕으로 새로운 상태를 만들어내는 함수입니다.

현재 상태(Current State)와 액션(Action)이라는 두 값을 받아서 새로운 상태를 반환하는 명확한 형태입니다.
이것을 말로 표현하면
"지금 상태는 이런데, 이런 액션이 들어오면, 지금 상태를 이런 상태로 바꿔서 반환하세요" 라는 의미입니다.
여기서 포인트는 리듀서(Reducer)가 기존 상태를 직접 수정하는 것이 아니라
액션(Action)에 따라 계산된 완전히 새로운 상태 객체를 다시 반환한다는 점입니다.
이제 리듀서(Reducer) 함수 안에, 행동을 처리하는 로직이 깔끔하게 모였다는 사실입니다.
이제는 이벤트 핸들러(Event Handler) 내부에서 상태를 하나하나 직접 바꿀 필요가 없습니다.
먼저 행동에 대한 로직은 리듀서(Reducer)에서 정의됩니다.
function cartReducer(state, action) {
switch(action) {
case "INCREASE_QTY": // 어떤 행동인지 확인합니다.
// 불변성을 지키며, 새 상태를 반환합니다.
return {
...state,
items: state.items.map((item) =>
item.id === action.payload.id ? {...item, qty: item.qty + 1} : item
),
totalPrice: state.totalPrice + 1,
discount: Math.max(0, state.discount - 1)
}
}
}이벤트 핸들러(Event Handler)는 단순히 어떤 행동이 일어났는지를 리듀서(Reducer)에게 알려주기만 하면 됩니다.
function increaseQty(id) {
dispatch({ type: "INCREASE_QTY", payload: { id });
}실제 상태 변경은 리듀서(Reducer)에서 집중적으로 이루어지기 때문입니다.
액션 및 리듀서의 구조
이제 비유를 통해 본격적으로 useReducer의 구조에 대해 파헤쳐봅시다.
1. 상태(State) : 식재료 재고표

상태는 현재 급식실에 남아있는 식재료 재고표라고 할 수 있으며, 식재료가 얼마나 남아있는지 적혀있는 표입니다.
2. 리듀서(Reducer) : 급식실 운영 메뉴얼

리듀서(Reducer)는 식재료를 어떻게 관리해야 하는지 적힌 급식실 운영 매뉴얼로써 규칙이 정리된 문서입니다.
3. 디스패치(Dispatch) : 전달 행위

급식실 직원이 "국이 다 떨어졌습니다" 라며, 요청서를 운영 매뉴얼 담당자에게 전달하는 행위입니다.
4. 액션(Action) : 요청서

"밥 10인분 추가 필요" 하다는 요청서 자체를 의미하며, 액션(Action)에 비유됩니다.
5. useReducer의 흐름

매뉴얼 확인 담당자에게 요청서를 제출하면 (Action 및 Reducer) 매뉴얼 확인 담당자가 확인하고
재고표를 수정합니다. 그리고 재고표에 따라 급식실 현황을 업데이트 합니다 (새 상태 반환)
6. 실제 구조
const [state, dispatch] = useReducer(reducer, initialState)useReducer Hook은 리듀서 함수(reducer)와 초기값(initialState)을 인자로 받게 됩니다.
reducer는 현재 상태와 액션 객체를 입력으로 받아, 새로운 상태를 반환하는 순수 함수이며
initialState는 컴포넌트의 초기 상태입니다.
useReducer Hook은 2개의 값을 반환하는데 최신 상태(state)와 액션을 전달하는 함수(dispatch)입니다.
Dispatch가 하는 역할은 type 및 payload가 담긴 액션 객체를 Reducer에 전달합니다.
여기서 주목해볼만한 Dispatch 함수의 특징은 컴포넌트가 아무리 리 랜더링 되어도 불변하여
React가 이 함수의 정체성을 보장해주어 안전하게 사용하고, 성능 최적화에 유리합니다.
7. 실제 흐름
dispatch를 통해 전달한 액션 객체는 리듀서의 2번째 인자로 들어오며
Reducer는 Action 타입을 보고 어떤 조치를 취할 지 결정합니다.
규칙에 따라 새로운 상태값을 반환하며, React가 변경된 상태를 감지하고 컴포넌트를 리 랜더링 합니다.

useReducer의 흐름 확장하기
useReducer를 사용하는데 액션 객체를 어떻게 확장하고, 리듀서를 어떻게 switch 패턴으로 구조화하는지 살펴봅니다.
1. 액션 객체 확장
간단한 예시에서 액션 객체에 type 속성만을 넣은 것과 달리 실제로는 행동 하나가
추가적인 정보를 필요로 하는 경우가 있을 수 있습니다.
payload라는 속성을 추가하게 되는데 이는 행동에 필요한 추가 데이터입니다.
dispatch({ type: "ADD_RICE", payload: { amount: 10 });2. 조건이 많아지는 것을 대비한 switch 패턴
급식실의 경우로 비유했을 때 문서 매뉴얼처럼 케이스별로 잘 정리되어 있는 모습도 비슷합니다.
적용하는 reducer 함수는 다음과 같습니다.
function reducer(state, action) {
switch(action.type) {
case "ADD_RICE" : {
// 블록 스코프로 변수 충돌을 방지합니다.
const updatedRice = state.rice + action.payload.amount;
return {...state, rice: upatedRice }
}
case "REFILL_SOUP":
return {...state, soup: state.soup + action.payload.amount }
case "CHANGE_SIDE":
return {...state, side: action.payload.newSide }
default:
// 예상치 못한 액션에 대한 안전 장치
return state;
}
}switch 문에서 action.type에 대한 경우를 처리할 때, 기존 상태는 유지하고 새로운 속성만 업데이트합니다.
추가적으로, 매뉴얼에 없는 요청에 대한 안전장치로 default인 경우를 선언해줍니다.
여기서 중요한 점은 reducer가 순수 함수여야 한다는 사실입니다.
외부 환경에 영향을 받지 않고, 전달받은 값들로만 예측 가능한 결과를 반환해야 합니다.
실무에서는 action.type의 오타를 방지하기 위해 별도의 객체로 만들어 상수로 관리하기도 합니다.
const ACTIONS = {
ADD_RICE: "ADD_RICE",
// ....
}
dispatch({ type: ACTIONS.ADD_RICE })실전 모듈화 패턴
실전에서는 행동(Action)이 증가하고, 상태(State)가 복잡해져 useReducer가 엄청나게 비대해집니다.
자연스럽게 switch문도 길어지고 관리해야 하는 데이터도 많아지며, 컴포넌트 하나에 모든 로직을 넣기에는
너무 버거운 순간이 찾아옵니다.

급식실을 예로 들었을 때
밥 담당과 국 담당, 반찬 담당이 다 따로 있다면 운영 규칙을 한 페이지에 끼워넣지는 않을 것입니다.
그리고 직원이 요청서를 작성할 때도 일일히 작성하기 보다는 요청서 자동 작성 양식이 있다면 편할 것입니다.
이것을 토대로 React에서 그대로 만든다면 훨씬 편할 것 같습니다.

1. 요청서 자동 생성기 (Action Creator)
요청서 자동 생성기는 액션이 많아지면, 간단한 함수를 만들어 필요한 내용을 자동으로 채워준 다음
액션 객체를 만들어주는 기능을 사용합니다. 이 기능을 전문 용어로는 Action Creator라고 부릅니다.
함수를 호출하면 내부에서 자동으로 객체를 만들어주는 방식으로 구성합니다.
이 방식을 사용하면 오타도 줄어들고, 액션 객체의 구조를 몰라도 쉽게 사용할 수 있으며 (추상화)
일관된 사용법을 유지할 수 있습니다.
이 Action Creator 함수의 자세한 구조를 살펴보겠습니다.
function addRice(amount) {
return {
type: ACTIONS.ADD_RICE,
payload: { amount }
}
}amount 라는 값을 인자로 받아서 type은 ADD_RICE로 정하고, amount 값을 payload 안에 넣어줍니다.
사용자는 이 함수를 호출하기만 하면, 정해진 규칙에 따라 객체를 반환해 dispatch의 인자에 넣어줍니다.
dispatch(addRice(10))이제 Action.type의 관리법을 좀 더 확장해서 알아보면,
문자열을 여기저기에서 직접 사용하기 보다는 하나의 상자에 정리해둘 수 있습니다.
ACTIONS 라는 상수를 만들어 그 안에 상수 객체를 저장할 수 있습니다. 다음처럼 말이죠!
export const ACTIONS = {
ADD_RICE: "ADD_RICE",
REFILL_SOUP: "REFILL_SOUP",
CHANGE_SIDE: "CHANGE_SIDE"
}이것을 Action Creator와 연결해 입력 값만으로 표준화된 요청서를 완성할 수 있습니다.
2. Reducer 파일 분리 아키텍처 만들기
거대한 리듀서(Reducer)를 역할에 맞게 분해하고 폴더 구조와 파일 이름을 바꾸어
누가봐도 이해할 수 있는 설계를 하여 유지보수 편의성을 확정짓는 방법에 대해 알아봅니다.
역할에 맞게 분해하는 이유는 Reducer 하나에 하나의 책임만 맡게 하기 위해서입니다.
다음과 같이 기능별로 리듀서(Reducer)를 나누는 것이며, 각 리듀서(Reducer)는 자기 역할에만 집중합니다.

이제 이렇게 나누는 것은 알겠는데, 어디에 어떻게 배치하는지도 살펴봐야 합니다.
실무에서 많이 사용하는 방식은 기능 중심 폴더 구성(Feature-Driven Folder)이며
src 폴더 아래 reducers 폴더를 만들고 그 아래 기능별 하위 폴더를 나누면 한 눈에 들어오게 됩니다.
구체적인 폴더 구조를 시각화하면 다음과 같습니다.

여기서 폴더 이름은 일반적으로 소문자-소문자 방식의 케밥 케이스를 사용합니다.
파일 이름은 카멜 케이스를 통해 역할을 명확하게 드러내도록 작성합니다.
예시로, rice 폴더 내부에 있는 riceReducer.js / riceActions.js / riceTypes.js를 살펴봅시다.
rice / riceReducer.js
export function riceReducer(state, action) {
switch(action.type) {
case "ADD_RICE": {
return {...state, stock: state.stock + 1 }
}
case "REMOVE_RICE": {
return {...state, stock: state.stock - 1 }
}
case "SET_RICE_WARNING": {
return {...state, warning: action.payload }
}
default:
return state;
}
}rice / riceActions.js
import RICE_ACTIONS from './riceTypes'
export function addRice() {
return { type: RICE_ACTIONS.ADD_RICE }
}
export function removeRice() {
return { type: RICE_ACTIONS.REMOVE_RICE }
}
export function setRiceWarning(message) {
return {
type: RICE_ACTIONS.SET_RICE_WARNING,
payload: message
}
}rice / riceTypes.js
export default RICE_ACTIONS = {
ADD_RICE: "ADD_RICE",
REMOVE_RICE: "REMOVE_RICE",
SET_RICE_WARNING: "SET_RICE_WARNING"
}이렇게 여러 개가 구성된 파일들을 새로 합치는 과정이 있어야 실제 적용할 수 있습니다.
import { riceReducer } from './rice/riceReducer';
import { soupReducer } from './soup/soupReducer';
import { sideReducer } from './side/sideReducer';
export function rootReducer(state, action) {
return {
rice: riceReducer(state.rice, action),
soup: soupReducer(state.soup, action),
side: sideReducer(state.side, action)
}
}미션 : 장바구니 로직 설계
지금까지 공부한 useReducer를 활용해 상태 전이 흐름을 정복해봅시다!
useReducer는 단순한 문법이 아니라
UI에서 발생하는 여러 종류의 변화를 하나의 상태 변화 흐름으로 설계하는 사고 방식입니다.
설계 흐름을 이해하는 것이 먼저 중요합니다!
이 흐름은 사용자 어떤 행동을 하면, 그 행동이 dispatch를 통해 리듀서(Reducer)로 전달되고
리듀서(Reducer)가 상태가 어떻게 바뀌어야 하는지 판별한 뒤, 새로운 상태를 만들어 UI가 다시 그려집니다.
여기서 상태가 바뀌는 곳은 오직 Reducer이므로, 중앙 집중적 상태 관리가 됩니다.
코드 작성 순서는 다음과 같이 정의할 수 있습니다.
- 첫번째 : 초기 상태 정의
- 두번째 : 액션 목록 정의
- 세번째 : 규칙 작성 (Reducer)
- 네번째 : useReducer Hook으로 Reducer와 상태를 연결
- 마지막 : dispatch로 액션을 전달
컴포넌트에서는 dispatch로 어떤 행동을 전달했는지만 알려주고, 형태가 어떻게 바뀌어야 하는지는 Reducer가 전부 맡습니다.
Step 1. 액션 타입 상수화
src/actions/cartActions.js
export const ADD = 'add'
export const REMOVE = 'remove'
export const INCREMENT = 'increment'
export const DECREMENT = 'decrement'Step 2. 액션 크리에이터
src/actions/cartActions.js
export function addItem(item) { return { type: ADD, item } }
export function removeItem(id) { return { type: REMOVE, payload: { id }}}
export function increment(id) { return { type: INCREMENT, payload: { id }}}
export function decrement(id) { return { type: DECREMENT, payload: { id }}}Step 3. 리듀서(Reducer)
src/reducers/cartReducer.js
import { ADD, REMOVE, INCREMENT, DECREMENT } from '../actions/cartActions';
export default function cartReducer(state, action) {
switch(action.type) {
case ADD:
return [...state, action.item]
case REMOVE:
return state.filter(p => p.id !== action.payload.id)
case INCREMENT:
return state.map(p => p.id === action.payload.id ? {...p, quantity: p.quantity + 1} : p)
case DECREMENT:
return state.map(p => p.id === action.payload.id ? {...p, quantity: p.quantity - 1} : p)
default:
return state
}
}Step 4. 컴포넌트에 적용하기
src/components/CartItem.jsx
import React from 'react';
export default function CartItem({ item, onIncrement, onDecrement, onRemove }) {
return (
<div className="cart-item">
<h3>{item.name}</h3>
<p>Quantity: {item.quantity}</p>
<div className="cart-item-actions">
<button onClick={onIncrement}>+</button>
<button onClick={onDecrement}>-</button>
<button onClick={onRemove}>Remove</button>
</div>
</div>
)
}src/cartApp.jsx
import React, { useReducer } from 'react'
import cartReducer from './reducers/cartReducer'
import { increment, decrement, remove, add } from './actions/cartAction'
export default function CartApp() {
const [state, dispatch] = useReducer(cartReducer, initialState)
function handleIncrement(id) {
dispatch(increment(id))
}
function handleDecrement(id) {
dispatch(decrement(id))
}
function handleRemove(id) {
dispatch(remove(id))
}
return (
<div className="cart-group">
{state.map((item) =>
<CartItem
key={p.id}
item={item}
onIncrement={() => handleIncrement(p.id)}
onDecrement={() => handleDecrement(p.id)}
onRemove={() => handleRemove(p.id)}
/>
)
}
</div>
)
}