오늘은 ReactJS에서 복잡한 상태 로직을 관리하기 위해 쓰는 useReducer 훅에 대해서 알아보겠슴다.
1. 그전에! Javascript에서 나오는 Reduce 개념을 먼저 알아봅시다
본격적으로 useReducer hook에 대해 알아보기전에, Javascript에서 나오는 이름이 비슷한 reduce 메서드에 대해 알아봅시다.
reduce 메서드는 배열을 순회하면서 누적값(acc)을 계속 업데이트해서 하나의 결과값을 만들어내는 함수인데요.
배열의 모든 요소를 하나로 합쳐, 돈을 차곡차곡 모으는 저금통이라고 생각하시면 편한데요.
array.reduce(callbackFn, initialValue);
reduce 함수의 기본적인 인자 구성은 위와 같은데요. 누적 계산을 수행하는 콜백 함수(필수)와 누적값의 초기값(숫자, 객체, 배열 등 가능, 필수 X)로 구성되어있습니다. 초기값부터 시작해서 배열의 모든 값에 대해 콜백함수를 실행하는거죠.
그리고 콜백 함수 안에는 또 최대 4개의 인자가 들어갈 수 있는데요.
array.reduce((accumulator, currentValue, currentIndex, array) => {
// 누적 로직
return newAccumulator;
}, initialValue);
바로 이렇게 말이죠. accumulator는 지금까지 누적된 결과 값, currentValue는 현재 처리 중인 배열 요소, index는 현재 요소의 인덱스, array는 원래 배열 전체값 입니다.
이렇게 쓰게 되면, 배열의 각 요소에 대해 인덱스와 전체 배열 정보를 함께 활용해 누적 로직을 수행하며, 그 결과를 다음 순서로 넘겨서 최종 하나의 값을 만드는 걸로 이해하시면 되겠습니다.
기본 문법말고 간단한 예시로 살펴보겠습니다. 제가 아까 저금통이라고 비유 했는데, 그에 맞는 코드를 하나 짰는데요.
const coins = [100, 500, 100, 50];
const total = coins.reduce((acc, coin) => acc + coin, 0);
console.log(total); // 750
- acc는 저금통(지금까지 모인 금액)
- coin은 매번 넣는 동전
- 0은 저금통의 시작 금액
이 경우, 하나씩 coin을 acc에 더하면서, 최종적으로 누적된 금액 값을 리턴하는 구조입니다.
2. 그래서 useReducer hook은 뭐고 reduce 메서드랑 무슨 상관이 있음?
useReducer도 useState처럼 상태 관리에 사용되는 hook인데요.
useReducer는 useState보다 조금 더 복잡한 상태를 체계적으로 관리할 때 사용하는 훨씬 똑똑한 hook으로, "상태를 관리하는 비서"로 생각하면 편합니다. 그러면 이게 reduce 메서드랑 무슨 관련이 있을까요?
그건 바로바로~~!
React의 useReducer가 이름 그대로 JavaScript의 reduce처럼 상태 변화 과정을 함수(reducer)로 "누적"하듯 관리하는 방식에서 착안해서 만들어진 hook이기 때문인데요.
reduce 메서드는 배열을 통한 계산용, useReducer hook은 상태관리용인것이죠. 비유를 통해 비교해보면,
- reduce: "저금통에 돈을 차곡차곡 넣어서 총합을 구하는 과정"
- useReducer: "비서에게 매번 명령을 내리고, 그에 따라 회사 상태가 바뀌는 과정"
으로 생각하면 편할 것 같습니다.
3. 그럼 이제 useReducer hook에 대해 자세히 araboza
useReducer hook의 기본적인 사용 구조는 다음과 같습니다.
import React, { useReducer } from 'react';
// initialState는 컴포넌트 밖에 정의하는 것이 일반적입니다.
const initialState = { count: 0 };
// reducerFunction도 컴포넌트 밖에 정의하는 것이 일반적입니다.
function reducerFunction(state, action) {
switch (action.type) {
case 'plus':
return { count: state.count + 1 };
case 'minus':
return { count: state.count - 1 };
case 'reset': // 리셋 기능도 추가해볼게요
return { count: 0 };
default:
// 알 수 없는 액션 타입이 들어오면 현재 상태를 그대로 반환합니다.
return state;
}
}
function Counter() {
// useReducer 훅 사용
// state: 현재 상태 객체 ({ count: 0 } 등)
// dispatch: 액션을 reducerFunction으로 전달하는 함수
const [state, dispatch] = useReducer(reducerFunction, initialState);
return (
<div>
{/* 현재 count 값을 화면에 표시 */}
<h1>Count: {state.count}</h1>
{/* 'plus' 액션을 dispatch 하는 버튼 */}
<button onClick={() => dispatch({ type: 'plus' })}>
증가 (+)
</button>
{/* 'minus' 액션을 dispatch 하는 버튼 */}
<button onClick={() => dispatch({ type: 'minus' })}>
감소 (-)
</button>
{/* 'reset' 액션을 dispatch 하는 버튼 */}
<button onClick={() => dispatch({ type: 'reset' })}>
초기화 (0)
</button>
</div>
);
}
export default Counter;
하나씩 뜯어봅시다.
(0) 작동 방식
작동 방식은 다음과 같습니다.
컴포넌트 밖에 초기 상태 initialState와 reducerFunction 정의 → 컴포넌트 안에서 useReducer hook의 결과값(현재 상태값과 호출할 때 쓸 함수)을 저장할 배열 선언 → 컴포넌트 안에서 실행할 action을 담은 dispatch 함수를 실행
그러면 작동 순서에 있는 요소들을 하나씩 뜯어봅시다.
(1) initialState – 상태의 초기값
처음 state 값이 뭔지 정해주는 인자입니다. useState hook에서 인자로 전달하는 초기값과 비슷한 역할을 한다고 보시면 됩니다. useReducer에 두 번째 인자로 전달됩니다.
(2) reducerFunction – 상태를 어떻게 바꿀지 정하는 비서 함수
reducerFunction은 상태를 어떻게 바꿀지 정하는 비서 함수입니다. 이 비서 함수는 또 state와 action 두 가지로 구성되는데요.
state는 현재 상태값, action은 { type: 'plus' } 처럼 컴포넌트 어딘가에서 실행된 dispatch 함수가 전달한 객체(명령서)입니다.
(3) dispatch – 상태 변경 요청을 보내는 함수
dispatch는 컴포넌트 내에서 실행할, 상태를 변경하라고 명령을 보낼 함수입니다.(=액션을 reducer로 보내서 state를 바꾸도록 요청하는 함수) 비서에게 명령을 내릴 전화기로 생각하면 편할지도요?
dispatch({ type: 'plus' }) ← 이렇게 쓰게 되면 reducerFunction 비서는 plus 카운터를 실행하고 관련 상태를 업데이트 하게 됩니다.
4. 언제 쓰면 좋을까?(장문주의)
React 애플리케이션의 상태 관리에는 useState와 useReducer라는 두 가지 강력한 훅이 있는데요.
useState는 간단한 상태 관리에 탁월하지만, 상태 로직이 복잡해지기 시작하면 useReducer가 더 나은 선택이 될 수 있습니다.
계속 예시를 들었던 것처럼 useReducer는 마치 상태 관리를 위한 '전문 비서'를 두는 것과 같거등여.
어떤 케이스들이 있을지 간단한 예시로 한번 러프하게 살펴봅시다.
(1) 여러 하위 값을 포함하는 복잡한 상태 전환이 있을 때
쇼핑 카트의 상태가 상품 목록, 총 가격, 배송료, 할인 코드 적용 여부 등으로 구성되어 있을 때, 상품을 추가하거나 삭제하면 이 모든 하위 값들이 연쇄적으로 변경되어야 합니다. 이런 경우 useReducer를 사용하면 '상품 추가'라는 하나의 액션으로 관련된 모든 상태 변화를 reducer 함수에서 통합 관리할 수 있습니다.
물론, useState로 이런 복잡한 객체를 관리할 수도 있지만, 상태 업데이트 로직이 길어지고 반복적으로 ...prevState를 사용하여 불변성을 유지하는 코드가 많아져 가독성이 떨어질 수 있겠죠?
(2) 다음 상태가 이전 상태에 따라 달라질 때
게임에서 플레이어가 특정 행동을 했을 때, 점수가 현재 점수와 특정 아이템의 효과에 따라 변하는 경우, 또는 사용자 입력 필드에서 이전 입력 값을 기반으로 자동 완성을 제안하거나, 여러 단계의 폼에서 이전 단계의 완료 여부에 따라 다음 단계로의 전환이 결정되는 경우가 여기에 해당하는데요. reducer는 항상 현재 상태(state)를 인자로 받기 때문에, 이전 상태를 기반으로 다음 상태를 계산하는 데 최적화되어 있습니다.
이것도 물론 useState의 setState(prevState => newState) 형태로도 가능하지만, 여러 상태 업데이트가 동시에 발생하거나 순서가 중요할 때는 useReducer의 reducer 함수가 이런 흐름을 훨씬 더 명확하게 표현하고 안전하게 관리할 수 있겠습니다.
(3) 관련된 일련의 액션(action)으로 상태를 관리해야 할 때
비동기 데이터를 불러오는 과정(로딩 시작 -> 성공/실패)을 관리할 때 useState로는 setIsLoading(true), setData(fetchedData), setIsLoading(false), setError(error) 등 여러 개의 setState 호출이 산발적으로 일어날 수 있는데요.
useReducer를 사용하면 dispatch({ type: 'FETCH_START' }), dispatch({ type: 'FETCH_SUCCESS', payload: data }), dispatch({ type: 'FETCH_ERROR', error: error })와 같이 명확한 액션을 통해 상태(로딩 상태, 데이터, 에러 등)를 하나의 reducer에서 일괄적으로 관리할 수 있습니다.
요건 코드를 통해 한 번 살펴봅시다.
- use State 쓰는 경우 :
import React, { useState, useEffect } from 'react';
function UserListWithUseState() {
const [isLoading, setIsLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUsers = async () => {
setIsLoading(true); // 로딩 시작
setError(null); // 이전 에러 초기화
setData(null); // 이전 데이터 초기화
try {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const users = await response.json();
setData(users); // 데이터 성공
} catch (err) {
setError(err); // 에러 발생
} finally {
setIsLoading(false); // 로딩 종료 (성공이든 실패든)
}
};
fetchUsers();
}, []); // 컴포넌트 마운트 시 한 번만 실행
if (isLoading) {
return <div>로딩 중...</div>;
}
if (error) {
return <div>에러 발생: {error.message}</div>;
}
if (!data) {
return <div>데이터가 없습니다.</div>;
}
return (
<div>
<h2>사용자 목록 (useState)</h2>
<ul>
{data.map(user => (
<li key={user.id}>{user.name} ({user.email})</li>
))}
</ul>
</div>
);
}
export default UserListWithUseState;
(1) 여러 개의 상태 변수: isLoading, data, error 각각을 별도의 useState 훅으로 해야함.
(2) 산발적인 setState 호출: fetchUsers 함수 내에서 로딩 시작, 성공, 실패, 종료 각 단계마다 3개의 setState 함수 중 여러 개를 호출해서 상태 간의 연관 관계가 명확하게 드러나지 않고, 어떤 setState가 어떤 로직을 담당하는지 한눈에 파악하기 어려움
(3) 일관성 관리의 어려움: 예를 들어 setIsLoading(false)를 빠뜨리거나, 에러 발생 시 setData(null)을 해주지 않으면 UI가 일관성 없는 상태에 놓일 위험이 있음
- useReducer 쓰는 경우
import React, { useReducer, useEffect } from 'react';
// 1. 초기 상태 정의
const initialFetchState = {
isLoading: false,
data: null,
error: null,
};
// 2. Reducer 함수 정의
// 이 함수는 현재 상태(state)와 액션(action)을 받아 새로운 상태를 반환합니다.
function fetchReducer(state, action) {
switch (action.type) {
case 'FETCH_START':
// 로딩 시작 액션: 로딩 중으로 설정하고 에러/데이터 초기화
return { ...state, isLoading: true, error: null, data: null };
case 'FETCH_SUCCESS':
// 성공 액션: 로딩 종료, 데이터 저장, 에러 초기화
return { ...state, isLoading: false, data: action.payload, error: null };
case 'FETCH_ERROR':
// 에러 액션: 로딩 종료, 에러 저장, 데이터 초기화
return { ...state, isLoading: false, error: action.error, data: null };
default:
// 알 수 없는 액션은 현재 상태 그대로 반환
return state;
}
}
function UserListWithUseReducer() {
// useReducer 훅 사용: fetchReducer와 initialFetchState 연결
const [state, dispatch] = useReducer(fetchReducer, initialFetchState);
useEffect(() => {
const fetchUsers = async () => {
dispatch({ type: 'FETCH_START' }); // 로딩 시작 액션 디스패치
try {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const users = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: users }); // 성공 액션 디스패치
} catch (err) {
dispatch({ type: 'FETCH_ERROR', error: err }); // 에러 액션 디스패치
}
};
fetchUsers();
}, []); // 컴포넌트 마운트 시 한 번만 실행
// 상태에 따라 UI 렌더링
if (state.isLoading) {
return <div>로딩 중...</div>;
}
if (state.error) {
return <div>에러 발생: {state.error.message}</div>;
}
if (!state.data) {
return <div>데이터가 없습니다.</div>;
}
return (
<div>
<h2>사용자 목록 (useReducer)</h2>
<ul>
{state.data.map(user => (
<li key={user.id}>{user.name} ({user.email})</li>
))}
</ul>
</div>
);
}
export default UserListWithUseReducer;
(1) 단일 상태 객체: isLoading, data, error를 하나의 state 객체 안에 통합하여 관리함.
(2) 명확한 액션 타입: FETCH_START, FETCH_SUCCESS, FETCH_ERROR와 같이 의미 있는 액션 타입을 정의해서 dispatch({ type: '...' })를 호출하는 것만으로 현재 어떤 상태 변화를 요청하는지 명확하게 알 수 있음.
(3) 중앙 집중화된 로직: fetchReducer 함수 안에서 로딩의 각 단계(시작, 성공, 실패)에 따른 모든 상태 변화 로직이 하나의 함수에 응집되어 있어 상태 변화의 일관성이 보장되고, 버그 발생 위험이 줄어듦.
(4) 예측 가능성: 어떤 액션이 들어오면 어떤 상태로 변할지 reducer 함수만 보면 명확하게 예측할 수 있음.
이상입니다.
읽어주셔서 감사합니다.
'웹개발 > ReactJS' 카테고리의 다른 글
[React] 메모이제이션으로 성능 최적화하기(useMemo, React.memo, useCallback 완전 비교) (1) | 2025.06.19 |
---|---|
[React] useRef hook에 대해 araboza (1) | 2025.06.19 |
[React] React 함수형 컴포넌트의 생명주기에 대해 araboza. (1) | 2025.06.11 |
[React] React에서 불변성이 뭘까? (0) | 2025.06.09 |
[React] 단방향 바인딩 vs 양방향 바인딩 (0) | 2025.06.04 |