| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |
- 배열
- 단위테스트
- ArrayList
- list
- 자바문법
- IntelliJ
- html
- SpringBoot
- math
- javascript
- input
- Array
- java테스트
- 문자열
- 자바스크립트
- 자바
- CSS
- 스프링부트
- 정규식
- vscode
- 인텔리제이
- junit5
- Java
- HashMap
- js
- Visual Studio Code
- string
- 테스트자동화
- Eclipse
- junit
- Today
- Total
어제 오늘 내일
[ React ] useReducer 사용법: 복잡한 상태 로직을 체계적으로 관리하기 본문
useState를 쓰다 보면 상태가 점점 복잡해지는 순간이 옵니다. 상태 값이 여러 개로 늘어나고, 상태를 바꾸는 로직이 컴포넌트 곳곳에 흩어지기 시작하죠. 이럴 때 상태 관리를 한곳으로 모아 체계적으로 다룰 수 있게 해주는 것이 바로 useReducer Hook입니다. 이 글에서는 useReducer가 무엇인지, useState와 어떻게 다른지, 언제 쓰면 좋은지 알아보겠습니다.
useReducer란?
useReducer는 상태 업데이트 로직을 컴포넌트 바깥으로 분리해서 관리할 수 있게 해주는 Hook입니다. Redux를 써본 적이 있다면 익숙할 텐데, 사실 useReducer는 Redux의 핵심 개념을 React 내장 기능으로 가져온 것입니다.
핵심 등장인물은 세 가지입니다.
- state: 현재 상태 값
- dispatch: "이런 일이 일어났다"고 알리는 함수
- reducer: 현재 상태와 action을 받아 새로운 상태를 계산하는 함수
이 셋이 어떻게 맞물리는지 예제로 살펴보겠습니다.
useState vs useReducer 비교
먼저 익숙한 useState로 만든 카운터를 봅시다.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
{/* 상태 변경 로직이 각 버튼에 흩어져 있음 */}
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(0)}>리셋</button>
</div>
);
}
이번엔 같은 기능을 useReducer로 바꿔보겠습니다.
import { useReducer } from 'react';
// reducer 함수: 현재 상태(state)와 action을 받아 새 상태를 반환
// 상태를 어떻게 바꿀지에 대한 모든 로직이 여기 한곳에 모임
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return { count: 0 };
default:
// 정의되지 않은 action이 들어오면 에러로 잡아냄
throw new Error('알 수 없는 action입니다: ' + action.type);
}
}
function Counter() {
// useReducer(reducer, 초기상태)
// state: 현재 상태 / dispatch: action을 보내는 함수
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<p>{state.count}</p>
{/* dispatch로 "무슨 일이 일어났는지"만 알림 */}
{/* 실제 상태 변경 방법은 reducer가 알아서 처리 */}
<button onClick={() => dispatch({ type: 'increment' })}>+1</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
<button onClick={() => dispatch({ type: 'reset' })}>리셋</button>
</div>
);
}
얼핏 코드가 더 길어 보이지만, 중요한 차이가 있습니다. 상태를 바꾸는 모든 로직이 reducer 함수 한곳에 모여 있다는 점입니다. 컴포넌트는 그저 "increment 액션이 일어났어"라고 dispatch로 알리기만 하면 되고, 실제로 어떻게 값을 바꿀지는 reducer가 책임집니다.
동작 흐름 이해하기
useReducer의 데이터 흐름은 다음과 같습니다.
- 사용자가 버튼을 클릭
dispatch({ type: 'increment' })로 action을 보냄- React가
reducer(현재상태, action)을 호출 - reducer가 새로운 상태를 반환
- 새 상태로 컴포넌트가 리렌더링
여기서 action은 "무슨 일이 일어났는지"를 설명하는 객체입니다. 보통 type 속성으로 종류를 구분하고, 추가 데이터가 필요하면 payload에 담습니다.
// action 객체의 일반적인 형태
dispatch({ type: 'increment' }); // 데이터가 필요 없는 경우
dispatch({ type: 'setCount', payload: 10 }); // 추가 데이터가 필요한 경우
실전 예제: 할 일 목록(Todo) 관리
카운터처럼 단순한 상태라면 굳이 useReducer를 쓸 필요가 없습니다. useReducer의 진가는 상태 구조가 복잡하고 업데이트 종류가 많을 때 드러납니다. 할 일 목록을 예로 들어보겠습니다.
import { useReducer } from 'react';
// 할 일 목록에 대한 모든 상태 변경 로직을 한곳에 정의
function todoReducer(state, action) {
switch (action.type) {
case 'add':
// 새 할 일을 배열에 추가
return [
...state,
{ id: Date.now(), text: action.payload, done: false }
];
case 'toggle':
// 특정 id의 할 일 완료 상태를 반전
return state.map(todo =>
todo.id === action.payload
? { ...todo, done: !todo.done }
: todo
);
case 'delete':
// 특정 id의 할 일을 목록에서 제거
return state.filter(todo => todo.id !== action.payload);
default:
throw new Error('알 수 없는 action: ' + action.type);
}
}
function TodoApp() {
// 초기 상태는 빈 배열
const [todos, dispatch] = useReducer(todoReducer, []);
const addTodo = (text) => {
// "할 일 추가" 액션과 함께 입력한 텍스트를 payload로 전달
dispatch({ type: 'add', payload: text });
};
return (
<div>
<button onClick={() => addTodo('React 공부하기')}>
할 일 추가
</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
{/* 클릭하면 해당 id의 완료 상태를 토글 */}
<span
onClick={() => dispatch({ type: 'toggle', payload: todo.id })}
style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
>
{todo.text}
</span>
{/* 삭제 버튼: 해당 id를 payload로 전달 */}
<button onClick={() => dispatch({ type: 'delete', payload: todo.id })}>
삭제
</button>
</li>
))}
</ul>
</div>
);
}
만약 이 로직을 전부 useState로 작성했다면, 추가/토글/삭제 로직이 컴포넌트 여기저기에 흩어져 코드를 읽기 어려웠을 것입니다. useReducer를 쓰면 "상태가 어떤 방식으로 바뀔 수 있는가"를 reducer 함수만 보면 한눈에 파악할 수 있습니다.
useState vs useReducer, 언제 무엇을 쓸까?
두 Hook의 차이를 정리하면 다음과 같습니다.
| 구분 | useState | useReducer |
| 적합한 상황 | 단순한 상태 (숫자, 문자열, 불리언) | 복잡한 상태 (여러 값이 얽힌 객체/배열) |
| 상태 변경 로직 | 컴포넌트 안에 흩어짐 | reducer 함수 한곳에 모임 |
| 코드량 | 적음 | 상대적으로 많음 |
| 테스트 | - | reducer만 따로 테스트하기 쉬움 |
다음과 같은 신호가 보이면 useReducer 도입을 고려해보세요.
- 상태 업데이트 로직이 여러 곳에 복잡하게 흩어져 있을 때
- 다음 상태가 이전 상태에 크게 의존할 때
useState가 너무 많아져서 관리하기 힘들 때- 상태 변경 방식(action)이 여러 종류일 때
반대로 단순한 토글이나 입력값 하나 정도라면 useState가 더 간결하고 적절합니다.
Context와 함께 쓰기: 미니 상태 관리 만들기
useReducer는 Context API와 결합하면 진짜 강력해집니다. Context로 상태와 dispatch를 앱 전체에 뿌리면, Redux 없이도 전역 상태 관리를 구현할 수 있습니다.
import { createContext, useContext, useReducer } from 'react';
const TodoContext = createContext();
// Provider 컴포넌트: reducer로 만든 상태와 dispatch를 하위에 공유
function TodoProvider({ children }) {
const [todos, dispatch] = useReducer(todoReducer, []);
return (
// state와 dispatch를 함께 value로 전달
<TodoContext.Provider value={{ todos, dispatch }}>
{children}
</TodoContext.Provider>
);
}
// 커스텀 Hook으로 편하게 꺼내 쓰기
function useTodos() {
return useContext(TodoContext);
}
// 이제 어느 컴포넌트에서든 아래처럼 사용 가능
function AddButton() {
const { dispatch } = useTodos();
return (
<button onClick={() => dispatch({ type: 'add', payload: '새 할 일' })}>
추가
</button>
);
}
이 패턴은 소규모~중규모 앱에서 Redux 같은 외부 라이브러리 없이도 충분히 훌륭한 전역 상태 관리 역할을 해냅니다. 실제로 많은 프로젝트가 "Context + useReducer" 조합으로 상태 관리를 해결합니다.
reducer 작성 시 주의할 점
reducer 함수를 작성할 때는 몇 가지 원칙을 지켜야 합니다.
1. 순수 함수로 작성하기
reducer는 같은 입력에 대해 항상 같은 출력을 반환하는 순수 함수여야 합니다. API 호출, 랜덤 값 생성, 타이머 같은 부수 효과(side effect)를 reducer 안에 넣으면 안 됩니다.
// ❌ reducer 안에서 부수 효과를 일으키면 안 됨
function reducer(state, action) {
fetch('/api/save'); // API 호출은 reducer 밖에서!
return { ...state };
}
2. 상태를 직접 수정하지 말고 새 객체 반환하기
기존 상태를 직접 변경하지 말고, 항상 새로운 상태 객체를 만들어 반환해야 합니다.
// ❌ 기존 state를 직접 수정 (불변성 위반)
function reducer(state, action) {
state.count += 1; // 직접 변경하면 안 됨
return state;
}
// ✅ 새로운 객체를 만들어 반환
function reducer(state, action) {
return { ...state, count: state.count + 1 };
}
React는 상태 객체의 참조가 바뀌었는지로 변경을 감지하기 때문에, 불변성을 지키지 않으면 화면이 제대로 갱신되지 않을 수 있습니다.
마무리
useReducer는 복잡한 상태 로직을 reducer 함수 한곳으로 모아 체계적으로 관리하게 해주는 Hook입니다. useState가 감당하기 버거워지는 순간, 즉 상태가 복잡하고 업데이트 종류가 많아질 때 진가를 발휘합니다.
핵심을 정리하면 이렇습니다. dispatch로 "무슨 일이 일어났는지"를 알리면, reducer가 "상태를 어떻게 바꿀지"를 책임진다. 이 역할 분리 덕분에 상태 관리가 예측 가능해지고, reducer만 따로 테스트하기도 쉬워집니다. 여기에 Context를 더하면 외부 라이브러리 없이도 훌륭한 전역 상태 관리 시스템을 만들 수 있으니, useState에 익숙해졌다면 다음 단계로 꼭 익혀두길 추천합니다.
'IT > React' 카테고리의 다른 글
| tailwind-merge로 Tailwind 클래스 충돌 깔끔하게 해결하기 (0) | 2026.08.07 |
|---|---|
| React에서 Tailwind CSS 제대로 활용하기 (설치, 활용) (0) | 2026.08.07 |
| [ React ] useCallback 사용법: 함수를 메모이제이션해 리렌더링 최적화하기 (0) | 2026.08.07 |
| [React] JSX 기초 완벽 가이드: React의 화면을 그리는 문법 (0) | 2026.08.03 |
| [React] useMemo 사용법: 불필요한 연산을 줄이는 메모이제이션 (0) | 2026.08.03 |