복잡한 상태의 해결사 - useReducer 첫 걸음과 선택 기준(useState vs useReducer)
리액트 공식문서 기반의 리액트 입문기
들어가며
이전 4-1편에서는 효율적인 상태 구조 설계의 중요성을, 4-2편에서는 상태 끌어올리기(Lifting State Up) 패턴을 통해 인접하거나 부모-자식 관계에 있는 컴포넌트 간에 상태를 공유하는 방법을 살펴보았습니다. useState Hook은 컴포넌트 내부에서 간단한 상태를 관리하는 데 매우 유용하고 직관적인 도구였습니다. 하지만 애플리케이션의 규모가 커지고, 상태 변화 로직이 복잡해지거나 여러 상태가 상호작용하기 시작하면 useState만으로는 코드의 가독성이 떨어지고 유지보수가 어려워질 수 있습니다. 마치 여러 개의 작은 스위치들을 개별적으로 조작하는 것과 비슷하다고 할 수 있습니다.
이런 상황에서 React는 useReducer Hook이라는 강력한 대안을 제공합니다. useReducer는 여러 상태가 얽혀 있는 복잡한 상태 로직을 효과적으로 관리하고, 예측 가능한 상태 변화를 만들어내는 데 도움을 줄 수 있습니다. 이번 편에서는 useReducer Hook이 무엇인지, 어떤 상황에서 유용하며, useState와는 어떤 차이가 있는지 심층적으로 탐구해 보겠습니다.
useReducer Hook의 기본 개념
useReducer는 React에서 컴포넌트의 상태를 관리하는 또 다른 방법입니다. 이름에서 알 수 있듯이, 이 Hook은 자바스크립트의 reduce 메서드와 유사하게 "리듀서(reducer)"라는 개념을 활용합니다. useReducer는 주로 다음 세 가지 핵심 요소와 함께 작동합니다.
14|- reducer 함수: 상태 변경 로직을 정의하는 순수 함수입니다. 현재 상태와 "액션(action)" 객체를 인자로 받아 새로운 상태를 반환합니다.
// 예시: 간단한 카운터 reducer
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
default:
// 알 수 없는 액션 타입의 경우 현재 상태를 반환하거나 에러를 발생시킬 수 있습니다.
return state;
}
}reducer 함수는 현재 상태와 액션을 받아서 새로운 상태를 반환하는 역할을 합니다. 위 예시에서는 INCREMENT 또는 DECREMENT 액션 타입에 따라 count 값을 변경한 새로운 상태 객체를 반환하는 모습을 볼 수 있습니다. 중요한 점은 기존 state를 직접 수정하지 않고 항상 새로운 객체를 반환해야 한다는 것입니다.
dispatch함수: 컴포넌트 내에서 상태 변경을 요청할 때 사용되는 함수입니다.action객체를 인자로 받아reducer함수를 실행시킵니다.
// 예시: dispatch 함수 사용
import { useReducer } from 'react';
// 간단한 카운터 reducer (예시의 독립성을 위해 포함)
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
default:
return state;
}
}
function MyCounterComponent() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return <button onClick={() => dispatch({ type: 'INCREMENT' })}>카운트 증가</button>;
}dispatch 함수는 상태를 변경하려는 "의도"를 action 객체에 담아 reducer에게 전달합니다. 위 코드에서는 버튼을 클릭했을 때 INCREMENT 타입의 액션을 dispatch하여 count 상태를 증가시키도록 요청하는 모습을 보여줍니다.
action객체: 어떤 상태 변경이 일어날지(type)와 필요한 데이터를 포함하는 객체입니다. 상태 변경의 "의도"를 나타냅니다.
// 예시: action 객체 형태
// 타입만 있는 액션
const incrementAction = { type: 'INCREMENT' };
// 타입과 추가 데이터(payload)가 있는 액션
const addTodoAction = {
type: 'ADD_TODO',
payload: { id: 1, text: '새로운 할 일', completed: false },
};action 객체는 type 속성을 필수로 가지며, 이는 어떤 종류의 상태 변화가 일어날지를 명시합니다. 필요한 경우 payload와 같은 추가 속성을 통해 상태 변경에 필요한 데이터를 함께 전달할 수 있습니다. 액션은 '무엇이 일어났는지'를 설명하며, reducer는 이 액션에 따라 '어떻게 상태를 변경할지'를 결정합니다.
위에서 살펴본 각 요소들이 실제 컴포넌트에서 어떻게 함께 작동하는지, 아래의 완전한 카운터 예시를 통해 useReducer의 전체적인 작동 방식을 더 자세히 살펴보겠습니다. 이 예시는 useState로도 충분히 구현 가능하지만, useReducer의 작동 원리를 이해하는 데 좋은 출발점이 될 것입니다.
useReducer 기본 구조 (카운터 예시)
// counterReducer.js
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const RESET = 'RESET';
export function counterReducer(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:
throw new Error();
}
}
// App.jsx (useReducer 사용 예시)
import { useReducer } from 'react';
import { counterReducer, INCREMENT, DECREMENT, RESET } from './counterReducer';
export default function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: INCREMENT })}>증가</button>
<button onClick={() => dispatch({ type: DECREMENT })}>감소</button>
<button onClick={() => dispatch({ type: RESET })}>리셋</button>
</div>
);
}위 코드에서 counterReducer 함수는 INCREMENT, DECREMENT, RESET이라는 세 가지 액션 타입에 따라 count 상태를 변경합니다. Counter 컴포넌트에서는 useReducer를 사용하여 state와 dispatch 함수를 가져오고, 버튼 클릭 시 해당 action을 dispatch 합니다. 이렇게 useReducer를 사용하면 상태 변화 로직을 컴포넌트 외부의 reducer 함수로 분리하여 관리할 수 있게 됩니다.
코드 작동 방식 설명
이 예시 코드는 간단한 카운터 기능을 useReducer를 사용해 구현한 것입니다. 작동 방식을 좀 더 자세히 살펴보겠습니다.
-
counterReducer.js파일: 이 파일은 상태를 변경하는reducer함수를 정의합니다.counterReducer함수는 현재 상태(state, 여기서는{ count: 0 }와 같은 객체)와dispatch함수에 의해 전달된action객체를 인자로 받습니다.switch (action.type)구문을 사용하여action.type값에 따라 다른 로직을 수행합니다.INCREMENT액션이 들어오면state.count에 1을 더한 새로운 상태 객체를 반환합니다. 여기서 중요한 것은return { count: state.count + 1 }처럼 항상 새로운 상태 객체를 반환해야 한다는 점입니다. 기존state를 직접 수정하면 React가 상태 변화를 감지하지 못해 UI가 업데이트되지 않을 수 있습니다.DECREMENT액션은 1을 빼고,RESET액션은count를 0으로 초기화합니다.- 정의되지 않은
action.type이 들어오면 에러를 발생시켜 예상치 못한 상태 변화를 방지합니다.
-
Counter.jsx컴포넌트: 이 컴포넌트는useReducer를 활용하여 카운터 UI를 렌더링하고 사용자 상호작용에 응답합니다.const [state, dispatch] = useReducer(counterReducer, { count: 0 });useReducerHook을 호출합니다. 첫 번째 인자로는 위에서 정의한counterReducer함수를 전달하고, 두 번째 인자로는{ count: 0 }라는 초기 상태를 전달합니다.useReducer는 두 가지 값을 반환합니다. 첫 번째는 현재 상태 값(state, 여기서는{ count: N }형태의 객체)이고, 두 번째는 상태 변경을 요청할 때 사용하는dispatch함수입니다.
<p>Count: {state.count}</p>: 현재state.count값을 화면에 표시합니다.<button onClick={() => dispatch({ type: 'INCREMENT' })}>증가</button>증가버튼을 클릭하면onClick이벤트 핸들러가 실행됩니다.dispatch({ type: 'INCREMENT' })를 호출하여INCREMENT액션 객체를reducer함수에 전달합니다. 그러면counterReducer는 이 액션을 처리하여count값을 증가시키고 새로운 상태를 반환합니다.React는 이 새로운 상태를 감지하여 컴포넌트를 다시 렌더링하고, 화면의count값이 업데이트됩니다.
감소,리셋버튼도 유사하게DECREMENT,RESET액션을dispatch하여 상태를 변경합니다.
결론적으로, useReducer는 상태 변경 로직을 reducer 함수로 분리하여 컴포넌트의 역할을 "UI 렌더링 및 액션 디스패치"에 집중시키고, 상태 변경의 "방법"은 reducer가 담당하도록 합니다. 이는 특히 복잡한 상태 로직을 구조화하고 관리하는 데 큰 이점을 제공합니다.
useReducer의 작동 원리 심층 분석
useReducer의 작동 원리는 세 단계로 요약할 수 있습니다. "액션 디스패치", "리듀서 실행", "상태 업데이트 및 리렌더링"입니다. 이 과정을 통해 React 애플리케이션의 상태가 예측 가능하게 관리됩니다.
-
액션 디스패치 (Dispatching an Action):
- 사용자의 상호작용(예: 버튼 클릭, 입력 값 변경)이나 특정 이벤트 발생 시, 컴포넌트 내부에서
dispatch함수를 호출하여action객체를 전달합니다.action객체는type속성을 필수로 가지며, 어떤 종류의 상태 변화가 필요한지 명시합니다. 필요한 경우 추가 데이터를payload속성에 담아 전달할 수 있습니다. - 예:
dispatch({ type: 'ADD_TODO', payload: { text: '새로운 할 일' } })
- 사용자의 상호작용(예: 버튼 클릭, 입력 값 변경)이나 특정 이벤트 발생 시, 컴포넌트 내부에서
-
리듀서 실행 (Running the Reducer):
dispatch함수가 호출되면,React는useReducer에 등록된reducer함수를 실행합니다. 이때reducer함수는 현재 상태(state)와dispatch를 통해 전달받은action객체를 인자로 받습니다.reducer함수는action.type에 따라 적절한 상태 변경 로직을 수행합니다. 중요한 점은reducer함수는 절대로 사이드 이펙트(side effect)를 일으켜서는 안 되는 순수 함수여야 한다는 것입니다. 즉, 동일한state와action이 주어지면 항상 동일한new state를 반환해야 하며, 외부 데이터를 직접 변경하거나 비동기 작업을 수행해서는 안 됩니다.
-
상태 업데이트 및 리렌더링 (State Update and Re-render):
reducer함수가 새로운 상태 객체를 반환하면,React는 이 새로운 상태와 이전 상태를 비교합니다.- 만약 상태가 실제로 변경되었다면(객체의 참조가 달라졌다면),
React는 해당 컴포넌트를 다시 렌더링하도록 스케줄링합니다. 이 과정에서useReducerHook은 새로운 상태 값을 반환하며, 컴포넌트는 업데이트된UI를 화면에 표시하게 됩니다. - 이러한 과정은
React의 단방향 데이터 흐름 원칙을 따르며, 상태 변화를 예측 가능하고 추적하기 쉽게 만듭니다. 컴포넌트는 오직 "무엇을 할지"(action을dispatch)만 알면 되고, "어떻게" 상태를 변경할지는reducer함수에 위임하게 됩니다.
이러한 메커니즘을 통해 useReducer는 복잡한 상태 로직을 컴포넌트로부터 분리하고, 상태 변화의 명확한 책임을 reducer 함수에 부여함으로써 코드의 구조화와 유지보수성을 크게 향상시킵니다.
useState vs useReducer: 선택 기준
React에서 컴포넌트 상태를 관리하는 두 가지 주요 Hook인 useState와 useReducer는 각각의 장단점이 있으며, 어떤 상황에서 더 적합한지 이해하는 것이 중요합니다. 둘 중 하나가 항상 "더 좋다"고 말할 수는 없으며, 프로젝트의 요구사항과 상태 로직의 복잡성에 따라 현명하게 선택해야 합니다.
언제 useState를 사용할까?
useState는 다음과 같은 상황에서 useReducer보다 더 간결하고 직관적인 해결책을 제공합니다.
-
상태가 단순한 원시값인 경우: 숫자, 문자열, 불리언 등 단일 값으로 표현되는 상태에 적합합니다.
- 예시:
const [count, setCount] = useState(0); - 예시:
const [isOn, setIsOn] = useState(false);
- 예시:
-
상태 업데이트 로직이 간단한 경우: 이전 상태에 의존하지 않고 단순히 값을 설정하거나, 토글하는 등 변화 로직이 한두 줄로 표현될 수 있을 때 유용합니다.
- 예시:
setCount(count + 1); - 예시:
setIsOn(!isOn);
- 예시:
-
독립적인 로컬 상태 관리: 다른 상태와 거의 상호작용하지 않고, 특정 컴포넌트 내에서만 관리되는 로컬 UI 상태에 적합합니다.
- 모달 창의 열림/닫힘 상태, 입력 필드의 현재 값 등.
useState는 적은 코드로 빠르게 상태를 정의하고 관리할 수 있어, 단순한 컴포넌트나 상태 로직이 복잡하지 않은 경우에 생산성을 높여줍니다.
언제 useReducer를 사용할까?
useReducer는 useState의 한계를 보완하며, 다음과 같은 복잡한 상태 관리 시나리오에서 빛을 발합니다.
-
복잡한 상태 로직이 있는 경우: 여러 하위 상태(
nested state)를 포함하는 객체 형태의 상태이거나, 다음 상태(next state)가 이전 상태(previous state)에 따라 복잡하게 결정될 때useReducer가 유리합니다. 모든 상태 변화 로직을reducer함수 한 곳에 모아두면, 컴포넌트 코드가 훨씬 깔끔해지고 상태 변화를 한눈에 파악하기 쉬워집니다.- 예시: 여러 필터 조건이 있는 검색 기능, 여러 단계의 폼 입력 상태 관리.
-
여러
dispatch호출이 상호작용하는 경우: 한 번의 사용자 액션으로 여러 상태가 복합적으로 변경되어야 할 때,useReducer는 이를 하나의action으로 추상화하여 관리할 수 있습니다. 이는 상태 변경의 일관성을 유지하고 잠재적인 버그를 줄이는 데 도움이 됩니다. -
전역 상태 관리가 필요한 경우:
Context API와 함께 사용하여 컴포넌트 트리 전반에 걸쳐 복잡한 전역 상태를 효율적으로 관리할 수 있습니다.reducer는 상태 로직을 처리하고Context는 상태와dispatch함수를 전달하는 역할을 분리하여, 대규모 애플리케이션의 상태 관리 아키텍처를 견고하게 만들 수 있습니다. (이 부분은 다음 4-5편에서 더 자세히 다룹니다.) -
상태 로직의 테스트 용이성:
reducer함수는 순수 함수이므로, 외부 환경에 의존하지 않고 독립적으로 테스트하기 매우 용이합니다. 이는 복잡한 비즈니스 로직을 포함하는 상태 관리 부분의 신뢰성을 높이는 데 기여합니다. -
협업 시 이점: 여러 개발자가 함께 작업할 때,
reducer함수는 상태 변경의 규칙(규약)을 명확하게 정의하므로, 다른 개발자가 상태 변화 로직을 쉽게 이해하고 기여할 수 있도록 돕습니다.
결론적으로, useState는 간단하고 독립적인 상태에, useReducer는 복잡하고 상호 연결된 상태에 더 적합하다고 할 수 있습니다. 상태가 많아지거나, 다음 상태를 결정하기 위해 여러 로직이 필요하다면 useReducer를 적극적으로 고려하는 것이 좋습니다.
다음 표는 useState와 useReducer의 주요 선택 기준을 요약한 것입니다.
| 특징/상황 | useState | useReducer |
|---|---|---|
| 상태 구조 | 단순한 원시값, 독립적인 객체/배열 | 복잡한 객체/배열 (중첩 상태), 여러 하위 상태 |
| 상태 업데이트 | 간단한 값 설정, 토글, 이전 상태에 의존하지 않는 경우 | 복잡한 로직, 이전 상태에 따라 복잡하게 결정되는 경우, 여러 업데이트가 한 번에 발생하는 경우 |
| 로직 분리 | 로직이 컴포넌트 내부에 직접 존재 | 로직이 reducer 함수로 분리되어 컴포넌트 외부에서 관리 |
| 코드 가독성 | 단순 상태에서는 높음 | 복잡 상태에서는 로직 분리로 가독성 향상 |
| 유지보수성 | 단순 상태에서는 좋음 | 복잡 상태에서 로직 집중화로 유지보수성 향상 |
| 테스트 용이성 | 컴포넌트 컨텍스트 필요 | reducer가 순수 함수이므로 독립적인 테스트 용이 |
| 협업 효율성 | 단순 로직에 적합 | 상태 변경의 규약 명확화로 협업 시 이점 |
| 주요 사용처 | 로컬 UI 상태 (모달, 입력값), 단순 카운터 | 전역 상태 관리(Context와 함께), Todo List, 장바구니, 복잡한 폼 |
참고: 여기서 "컴포넌트 컨텍스트"란, 컴포넌트가 마치 자신의 "작업 공간"처럼 사용하는 특정 React 실행 환경을 의미합니다.
useState로 관리되는 상태는 주로 이 작업 공간 안에 존재하며, 외부에서 이 상태에 직접 접근하기 어렵습니다. 따라서useState로 관리되는 상태를 테스트할 때는 해당 컴포넌트를 직접 렌더링하고 상호작용한 후 결과를 확인하는 방식으로 테스트 환경을 구축해야 합니다.
useReducer 사용 시 고려사항 및 최적화
useReducer는 강력한 상태 관리 도구이지만, 효과적으로 사용하기 위해서는 몇 가지 고려사항과 최적화 기법을 알아두는 것이 좋습니다.
-
reducer함수는 항상 순수 함수여야 합니다:- 외부 스코프에 영향을 주지 않아야 합니다:
reducer함수 외부의 변수를 변경하거나, API 호출, 타이머 설정 등의 사이드 이펙트를 발생시키지 않아야 합니다. - 동일한 입력에 대해 항상 동일한 출력: 같은
state와action이 주어지면 항상 같은new state를 반환해야 합니다.Math.random(),Date.now()와 같이 예측 불가능한 값을 사용해서는 안 됩니다. - 기존 상태를 직접 수정하지 않아야 합니다: 객체나 배열과 같은 참조 타입의 상태를 직접
push,pop,splice, 속성 변경 등으로 수정해서는 안 됩니다. 대신 스프레드 문법(...)이나map,filter등의 배열 메서드를 사용하여 새로운 객체나 배열을 생성하여 반환해야 합니다. 이는React가 상태 변경을 정확히 감지하고 효율적으로UI를 업데이트하는 데 필수적입니다.
- 외부 스코프에 영향을 주지 않아야 합니다:
-
초기화 함수(Lazy Initialization) 활용:
useReducer의 세 번째 인자로 초기화 함수를 전달할 수 있습니다. 이 함수는 초기 상태를 지연 계산해야 할 때 유용합니다. 특히 초기 상태 계산이 복잡하거나 비용이 많이 드는 경우에 초기화 함수를 사용하면, 컴포넌트가 처음 렌더링될 때 한 번만 실행되므로 성능을 최적화할 수 있습니다.
-
action객체의 구조화:action객체는type속성을 필수로 포함하며, 일반적으로payload속성을 사용하여 상태 변경에 필요한 추가 데이터를 전달합니다.action타입을 상수로 정의하여 오타로 인한 버그를 방지하고 가독성을 높이는 것이 좋습니다.
-
immer와 같은 라이브러리 고려 (복잡한 중첩 상태):- 상태 객체의 중첩이 깊어지면 불변성 유지를 위해 스프레드 문법을 반복적으로 사용해야 하여 코드가 복잡해질 수 있습니다. 이때
Immer와 같은 라이브러리를 사용하면, "데이터를 직접 수정하는 것처럼" 코드를 작성하면서도 불변성을 자동으로 유지할 수 있어 생산성을 크게 높일 수 있습니다.Immer는 내부적으로 변경 불가능한 상태를 효율적으로 관리해 줍니다.
- 상태 객체의 중첩이 깊어지면 불변성 유지를 위해 스프레드 문법을 반복적으로 사용해야 하여 코드가 복잡해질 수 있습니다. 이때
요약
이번 편에서는 React에서 복잡한 상태 로직을 효과적으로 관리하기 위한 useReducer Hook의 기본 개념, 작동 원리, 그리고 useState와 비교하여 언제 useReducer를 사용해야 하는지 선택 기준을 심층적으로 살펴보았습니다. 또한, useState로 구현된 Todo List를 useReducer로 마이그레이션하는 과정을 통해 실제 적용 방법을 이해하고, useReducer 사용 시 고려해야 할 중요 사항과 최적화 기법까지 다루었습니다.
공부한 것을 정리해보면 다음과 같습니다.
useReducer핵심: 복잡한 상태 로직을reducer함수로 분리하여 관리하는ReactHook.- 주요 구성 요소:
reducer(상태 변경 로직),dispatch(액션 전달),action(상태 변경 의도). - 작동 원리:
dispatch→reducer실행 (순수 함수) → 상태 업데이트 및 리렌더링. useStatevsuseReducer:useState: 단순한 상태, 간단한 업데이트 로직, 독립적인 로컬 상태에 적합.useReducer: 복잡한 상태 로직, 여러dispatch의 상호작용, 전역 상태 관리(Context와 함께), 테스트 용이성, 협업 시 이점에 유리.
useReducer는 useState만으로는 다루기 어려운 복잡한 상태를 체계적이고 예측 가능하게 관리할 수 있는 강력한 도구입니다. 상태 로직을 컴포넌트로부터 분리함으로써 코드의 가독성, 유지보수성, 그리고 테스트 용이성을 크게 향상시킬 수 있다는 점을 기억하는 것이 중요합니다.
다음 4-4편에서는 useState로 구현된 컴포넌트의 상태 관리 로직을 useReducer 기반으로 전환하는 과정을 심층적으로 탐구해 보겠습니다.
참고문서
– [useReducer로 State 로직 추출하기 (Extracting State Logic into a Reducer)]