[3편] React useReducer 완벽 가이드 - useState를 넘어서는 상태 관리
컴포넌트 하나에 useState가 10개 넘게 들어가 있는 코드 보신 적 있죠? 저도 처음엔 그랬어요. “상태 하나 추가할 때마다 useState 하나 더 쓰면 되지 뭐” 이렇게 생각했거든요. 그런데 프로젝트가 커지면서… 어느 순간 컴포넌트 파일을 열면 스크롤이 끝도 없이 내려가고, 어디서 어떤 상태를 바꾸는지 찾느라 ctrl+F를 연타하고 있는 제 모습을 발견했습니다.
그때 선배 개발자가 말했죠. “야, useReducer 한번 써봐.”
솔직히 처음엔 “뭔가 더 어려워 보이는데…” 싶었어요. 근데 막상 써보니까, 아… 이게 진짜 상태 관리구나 싶더라고요. 오늘은 제가 그때 겪었던 삽질을 여러분은 안 하셨으면 해서, useReducer를 처음 접하는 분들도 쉽게 이해할 수 있도록 최대한 쉽게 풀어서 설명해 드리려고 합니다.
useState의 현실: 왜 복잡해질까?
여러분이 간단한 카운터 앱을 만든다고 생각해보세요. useState 하나면 충분하죠.
const [count, setCount] = useState(0);
그런데 실무는 이렇게 호락호락하지 않아요. 사용자 정보도 관리하고, 모달 열림/닫힘도 관리하고, 폼 입력값도 관리하고… 어느새 이렇게 됩니다.
const [count, setCount] = useState(0);
const [user, setUser] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [formData, setFormData] = useState({});
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
// ... 계속 늘어나는 useState들
여기까지는 그래도 괜찮아요. 진짜 문제는 이 상태들을 변경하는 함수들이에요.
const handleIncrement = () => {
setCount(count + 1);
// 근데 count가 10 넘으면 모달 띄워야 하고...
if (count + 1 > 10) {
setIsModalOpen(true);
}
};
const handleUserUpdate = (newData) => {
setUser(newData);
// 유저 정보 바뀌면 폼도 초기화하고...
setFormData({});
setErrors({});
// 로딩 상태도 관리하고...
setLoading(false);
};
// 이런 함수들이 계속 늘어나면...
보이시나요? 각 함수 안에서 여러 setState를 호출하고, 함수들끼리 서로 얽혀있고… 이게 바로 “스파게티 코드”가 만들어지는 순간입니다.
저는 한번은 이런 코드에서 버그를 찾느라 3시간을 날린 적이 있어요. 어디선가 setUser를 호출했는데, 그게 연쇄적으로 다른 상태들을 바꾸고, 그게 또 다른 useEffect를 트리거하고… 디버깅하다가 머리가 깨질 뻔했죠.
useReducer: 상태 관리의 구원투수
여기서 등장하는 게 useReducer입니다. 핵심 아이디어는 간단해요.
식당으로 비유해볼게요.
- 손님(컴포넌트): “불고기 하나요!”
- 주문서(Action):
{메뉴: "불고기", 수량: 1, 맵기: "중간"} - 홀 직원(dispatch): 주문서를 주방으로 전달
- 주방장(Reducer): 주문서 보고 요리 완성
- 완성된 요리(State): 테이블로 나감
손님은 요리를 어떻게 만드는지 몰라도 돼요. 그냥 주문만 하면 되는 거죠. 이게 바로 useReducer의 철학입니다.
useReducer의 3대 요소
graph LR
A[컴포넌트] -->|dispatch| B[Action 객체]
B -->|전달| C[Reducer 함수]
C -->|새로운 State| D[컴포넌트 리렌더링]
D -->|사용자 이벤트| A
1. Dispatch (디스패치)
- 상태 변경을 요청하는 함수
- “야, 상태 좀 바꿔줘!” 하고 외치는 역할
dispatch({ type: "INCREMENT" })이런 식으로 사용
- 무엇을 할지 담은 정보
- 보통
type속성은 필수, 추가 데이터는 선택 { type: "ADD_TODO", content: "장보기" }이런 형태
- 실제로 상태를 변경하는 로직
- 현재 상태와 액션을 받아서 새 상태를 반환
- 컴포넌트 밖에 있어도 되는 게 포인트!
실습 1: 카운터로 감 잡기
이론만 들으면 어려워요. 직접 만들어봐야 감이 옵니다. 가장 간단한 카운터부터 시작해볼게요.
기존 방식 (useState)
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const onIncrease = () => {
setCount(count + 1); // 컴포넌트 안에서 직접 변경
};
const onDecrease = () => {
setCount(count - 1); // 컴포넌트 안에서 직접 변경
};
return (
<div>
<h1>{count}</h1>
<button onClick={onIncrease}>+</button>
<button onClick={onDecrease}>-</button>
</div>
);
}
이 코드의 한계는 뭘까요? onIncrease, onDecrease 함수가 반드시 컴포넌트 안에 있어야 한다는 거예요. 컴포넌트가 커지면 이런 함수들이 수십 개가 될 수 있습니다.
useReducer 방식
이제 같은 기능을 useReducer로 바꿔볼게요.
import { useReducer } from 'react';
// 1. Reducer 함수 - 컴포넌트 밖에 작성!
function reducer(state, action) {
// action.type에 따라 다른 처리
switch (action.type) {
case 'INCREMENT':
return state + 1; // 새로운 상태 반환
case 'DECREMENT':
return state - 1; // 새로운 상태 반환
case 'RESET':
return 0; // 초기화
default:
return state; // 모르는 타입이면 기존 상태 유지
}
}
function Counter() {
// 2. useReducer로 선언
// [현재상태, 요청함수] = useReducer(상태변경함수, 초기값)
const [count, dispatch] = useReducer(reducer, 0);
return (
<div>
<h1>{count}</h1>
{/* 3. dispatch로 요청만 보냄 */}
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
<button onClick={() => dispatch({ type: 'RESET' })}>0으로</button>
</div>
);
}
차이가 보이시나요?
- Reducer는 컴포넌트 밖에 있어요. 심지어 다른 파일에 있어도 됩니다.
- 컴포넌트는 가벼워졌어요. 그냥
dispatch로 요청만 보내면 끝. - 기능 추가가 쉬워요. “0으로 초기화” 버튼을 추가하는 데
reducer함수에case하나만 추가하면 됐죠.
작동 원리를 천천히 따라가보기
버튼을 눌렀을 때 무슨 일이 일어나는지 단계별로 볼게요.
// 사용자가 + 버튼 클릭
onClick={() => dispatch({ type: 'INCREMENT' })}
// ↓ 1단계: dispatch 호출
// { type: 'INCREMENT' } 객체를 reducer에게 전달
// ↓ 2단계: reducer 함수 실행
function reducer(state, action) {
// state는 현재값 (예: 5)
// action은 방금 받은 { type: 'INCREMENT' }
switch (action.type) {
case 'INCREMENT':
return state + 1; // 5 + 1 = 6을 반환
}
}
// ↓ 3단계: 리액트가 새로운 상태로 업데이트
// count가 6으로 바뀌고 컴포넌트 리렌더링
처음엔 “왜 이렇게 복잡하게?” 싶을 수 있어요. 근데 상태 변경 로직이 5개, 10개로 늘어나면 그때 진가를 발휘합니다.
실습 2: 할 일 앱으로 실전 연습
자, 이제 진짜 실무에 가까운 예제를 해볼게요. 할 일(Todo) 앱입니다. 기능은 세 가지예요.
- 추가 (Create): 새 할 일 만들기
- 수정 (Update): 완료 체크 토글
- 삭제 (Delete): 할 일 지우기
준비: useState에서 useReducer로 전환
먼저 기존 useState 코드를 정리해볼게요.
// 기존 방식
const [todos, setTodos] = useState([
{ id: 1, content: "운동하기", isDone: false },
{ id: 2, content: "공부하기", isDone: true },
]);
이걸 useReducer로 바꾸면:
import { useReducer, useRef } from 'react';
// Reducer 함수 (일단 빈 껍데기)
function reducer(state, action) {
switch (action.type) {
// 여기에 로직을 하나씩 추가할 거예요
default:
return state;
}
}
function TodoApp() {
// useReducer 선언
const [todos, dispatch] = useReducer(reducer, [
{ id: 1, content: "운동하기", isDone: false },
{ id: 2, content: "공부하기", isDone: true },
]);
// ID 생성용 (할 일마다 고유 ID 필요)
const idRef = useRef(3);
// 이제 onCreate, onUpdate, onDelete 함수를 만들 거예요
}
graph TD
A[사용자 액션] --> B{어떤 액션?}
B -->|새 할일 추가| C[CREATE]
B -->|완료 체크| D[UPDATE]
B -->|할일 삭제| E[DELETE]
C --> F[Reducer 처리]
D --> F
E --> F
F --> G[새로운 todos 배열]
G --> H[화면 업데이트]
기능 1: 할 일 추가 (CREATE)
새로운 할 일을 추가하는 기능부터 만들어볼게요.
1단계: 컴포넌트에서 dispatch 호출const onCreate = (content) => {
// 새로운 할 일 객체를 만들어서 dispatch
dispatch({
type: 'CREATE',
newItem: {
id: idRef.current, // 고유 ID
content: content, // 사용자가 입력한 내용
isDone: false, // 처음엔 미완료
createdDate: new Date().getTime(), // 생성 시간
},
});
idRef.current += 1; // 다음 ID를 위해 증가
};
여기서 중요한 건, 컴포넌트는 그냥 객체만 만들어서 던진다는 거예요. “이걸 배열에 어떻게 추가할지”는 고민 안 해도 됩니다.
2단계: Reducer에서 실제 로직 처리function reducer(state, action) {
switch (action.type) {
case 'CREATE':
// 새 아이템을 기존 배열 맨 앞에 추가
// [새아이템, ...기존아이템들] 형태
return [action.newItem, ...state];
default:
return state;
}
}
보세요! 배열에 추가하는 로직이 reducer 안에 딱 한 곳에만 있어요. 나중에 “아, 맨 앞이 아니라 맨 뒤에 추가하고 싶다”면 이 한 줄만 바꾸면 됩니다.
// 사용자가 "장보기" 입력 후 버튼 클릭
onCreate("장보기")
// ↓
dispatch({
type: 'CREATE',
newItem: { id: 3, content: "장보기", isDone: false, ... }
})
// ↓
reducer가 받아서
case 'CREATE':
return [새아이템, ...기존todos]
// ↓
todos 배열이 업데이트되고 화면에 "장보기"가 추가됨!
기능 2: 할 일 수정 (UPDATE)
체크박스를 클릭하면 완료/미완료 상태를 바꾸는 기능이에요.
1단계: dispatch로 어떤 할 일을 수정할지 알림const onUpdate = (targetId) => {
dispatch({
type: 'UPDATE',
targetId: targetId, // 수정할 할 일의 ID만 보냄
});
};
간단하죠? “ID가 5번인 할 일 좀 수정해줘”만 말하면 돼요.
2단계: Reducer에서 해당 할 일 찾아서 토글case 'UPDATE':
// map으로 전체 배열 순회
return state.map((todo) =>
// ID가 일치하면
todo.id === action.targetId
? { ...todo, isDone: !todo.isDone } // isDone 반전 (true ↔ false)
: todo // ID 다르면 그대로 유지
);
이 코드를 말로 풀면:
- 모든 할 일을 하나씩 확인해
- ID가 맞는 할 일 찾으면,
isDone을 반대로 바꿔 (true면 false, false면 true) - ID가 안 맞으면 그냥 그대로 둬
- 새로운 배열을 만들어서 반환해
// 현재 todos
[
{ id: 1, content: "운동", isDone: false },
{ id: 2, content: "공부", isDone: true },
]
// 사용자가 "운동" 체크박스 클릭
onUpdate(1)
// ↓
dispatch({ type: 'UPDATE', targetId: 1 })
// ↓
reducer가 map 돌면서
id:1 → isDone: false를 true로 변경
id:2 → 그대로 유지
// 결과
[
{ id: 1, content: "운동", isDone: true }, // 바뀜!
{ id: 2, content: "공부", isDone: true },
]
기능 3: 할 일 삭제 (DELETE)
마지막으로 삭제 기능이에요. 이건 더 간단합니다.
1단계: dispatch로 삭제 요청const onDelete = (targetId) => {
dispatch({
type: 'DELETE',
targetId: targetId,
});
};
2단계: Reducer에서 해당 할 일 제거
case 'DELETE':
// filter로 targetId와 다른 것만 남김
return state.filter((todo) => todo.id !== action.targetId);
filter는 조건에 맞는 것만 남기는 함수예요. 여기서는 “ID가 targetId가 아닌 것들만 남겨”라고 한 거죠.
// 현재 todos
[
{ id: 1, content: "운동", isDone: true },
{ id: 2, content: "공부", isDone: true },
]
// "운동" 삭제 버튼 클릭
onDelete(1)
// ↓
dispatch({ type: 'DELETE', targetId: 1 })
// ↓
reducer가 filter 실행
id:1 → 1 !== 1 (false) → 제외!
id:2 → 2 !== 1 (true) → 포함!
// 결과
[
{ id: 2, content: "공부", isDone: true },
]
전체 코드 완성본
여기까지 한 걸 다 합치면 이렇게 됩니다.
import { useReducer, useRef } from 'react';
// Reducer: 모든 상태 변경 로직이 여기 모여있음
function reducer(state, action) {
switch (action.type) {
case 'CREATE':
return [action.newItem, ...state];
case 'UPDATE':
return state.map((todo) =>
todo.id === action.targetId
? { ...todo, isDone: !todo.isDone }
: todo
);
case 'DELETE':
return state.filter((todo) => todo.id !== action.targetId);
default:
return state;
}
}
function TodoApp() {
const [todos, dispatch] = useReducer(reducer, [
{ id: 1, content: "운동하기", isDone: false },
{ id: 2, content: "공부하기", isDone: true },
]);
const idRef = useRef(3);
// 컴포넌트는 가벼워요. 그냥 dispatch만 호출!
const onCreate = (content) => {
dispatch({
type: 'CREATE',
newItem: {
id: idRef.current,
content,
isDone: false,
createdDate: new Date().getTime(),
},
});
idRef.current += 1;
};
const onUpdate = (targetId) => {
dispatch({ type: 'UPDATE', targetId });
};
const onDelete = (targetId) => {
dispatch({ type: 'DELETE', targetId });
};
return (
// UI 렌더링...
);
}
보세요. 컴포넌트 안은 정말 깔끔하죠? 복잡한 로직은 다 reducer에 있고, 컴포넌트는 “언제 어떤 액션을 발생시킬지”만 결정합니다.
핵심 정리: useReducer를 쓰면 뭐가 좋을까?
여기까지 따라오시느라 고생 많으셨어요. 마지막으로 핵심만 정리해볼게요.
1. 역할 분리가 명확해진다
- 컴포넌트: “무엇을 할지” 결정 (UI와 이벤트)
- Reducer: “어떻게 할지” 처리 (데이터 로직)
마치 회사에서 기획자와 개발자가 역할을 나누는 것처럼요.
2. 코드 유지보수가 쉬워진다
버그가 생기면 어디를 봐야 할지 명확해요.
- 화면이 이상하면 → 컴포넌트 확인
- 데이터가 이상하면 → Reducer 확인
“아니 이 상태가 어디서 바뀐 거야?!” 하면서 파일 10개 뒤지는 일이 없어집니다.
3. 테스트하기 좋다
reducer는 순수 함수예요. 같은 입력에 항상 같은 출력을 내놓죠. 이게 테스트하기 엄청 쉬워요.
// 테스트 예시
const result = reducer(
[{ id: 1, content: "운동", isDone: false }],
{ type: 'UPDATE', targetId: 1 }
);
// result[0].isDone이 true인지 확인만 하면 됨
컴포넌트를 렌더링하거나 클릭 이벤트를 시뮬레이션할 필요가 없어요.
4. 확장성이 좋다
새로운 기능 추가할 때 기존 코드를 거의 안 건드려도 돼요.
// "전체 완료" 기능 추가
case 'COMPLETE_ALL':
return state.map(todo => ({ ...todo, isDone: true }));
// "전체 삭제" 기능 추가
case 'DELETE_ALL':
return [];
reducer에 case 몇 줄만 추가하면 끝이에요.
자주 하는 실수와 팁
제가 처음 useReducer 쓸 때 했던 실수들 공유할게요.
// dispatch에서는
dispatch({ type: 'CREAT' }) // 오타!
// reducer에서는
case 'CREATE': // 매칭 안 됨
타입을 상수로 빼두면 이런 실수를 방지할 수 있어요.
const ACTION_TYPES = {
CREATE: 'CREATE',
UPDATE: 'UPDATE',
DELETE: 'DELETE',
};
dispatch({ type: ACTION_TYPES.CREATE });
실수 2: state를 직접 수정
// ❌ 잘못된 코드
case 'UPDATE':
state[0].isDone = true; // state 직접 수정!
return state;
// ✅ 올바른 코드
case 'UPDATE':
return state.map(...) // 새 배열 반환
리액트는 참조가 바뀌어야 리렌더링해요. 기존 state를 수정하면 변화를 감지 못 합니다.
팁: action 객체 구조 일관성 유지저는 보통 이런 구조를 선호해요:
{
type: 'ACTION_TYPE', // 필수
payload: { ... } // 데이터는 payload에 통일
}
이렇게 하면 reducer에서 action.payload로 통일감 있게 접근할 수 있어요.
언제 useState를 쓰고, 언제 useReducer를 쓸까?
모든 상태를 useReducer로 관리할 필요는 없어요. 상황에 맞게 쓰시면 됩니다.
- 상태가 1-2개 정도로 간단할 때
- 상태 변경 로직이 단순할 때
setCount(count + 1)이런 게 전부일 때
- 상태가 객체나 배열처럼 복잡할 때
- 하나의 이벤트가 여러 상태를 바꿀 때
- 상태 변경 로직이 복잡하거나 조건이 많을 때
- 다른 팀원과 협업할 때 (로직 분리가 명확해서)
저는 보통 이렇게 판단해요: “이 컴포넌트가 50줄 넘어가면 useReducer 고려해볼까?”
다음 단계: 여기서 더 나아가려면
useReducer를 익혔다면 다음으로 공부할 만한 것들이에요.
- Context API와 함께 쓰기:
useReducer+useContext조합으로 전역 상태 관리 - 미들웨어 패턴: 로깅, 비동기 처리 등을 추가하는 방법
- Redux 공부하기:
useReducer의 개념을 그대로 확장한 게 Redux예요 - TypeScript 적용: action type을 타입으로 정의하면 더 안전해져요
마치며
처음엔 “왜 이렇게 복잡하게?” 싶었던 useReducer가 이제 좀 친근하게 느껴지시나요?
저도 처음엔 그랬어요. 근데 실제로 프로젝트에서 써보니까, 특히 팀으로 일할 때 진짜 편하더라고요. “어? 이 기능 어떻게 추가하지?” 할 때 reducer 파일 하나만 열어보면 되니까요.
완벽하게 이해하려고 너무 애쓰지 마세요. 일단 작은 프로젝트 하나 만들면서 직접 써보는 게 제일 빨라요. 카운터든, To-do든, 뭐든 좋으니까 “아, 이게 이렇게 동작하는구나” 체득하는 게 중요합니다.
혹시 따라하다가 막히는 부분 있으면 댓글로 물어보세요. 제가 겪었던 삽질을 여러분은 안 하셨으면 좋겠어요!
그럼 즐거운 코딩 되세요! 🚀
참고 자료: 이 글은 ‘한입 크기로 잘라 먹는 리액트’를 바탕으로 작성되었습니다.