useState 대신 useReducer를 선택해야 하는 순간
React 개발을 하다 보면 대부분의 상태 관리는 useState로 충분하다. 하지만 프로젝트가 커지고 단순한 컴포넌트여도 내부의 상태가 점점 복잡해질수록, useState만으로는 관리가 어려워지는 순간이 온다.
오늘은 실제 사내 서비스 개발 중 겪었던 문제를 사례로 들어, 왜 useReducer가 필요한지 이야기해보겠다.
언제 useReducer가 필요한가
useReducer를 사용해야 하는 시점은 명확하다.
- 복잡한 상태 구조로 여러 하위 값들을 관리해야 할 때
- 새로운 상태 값이 이전 상태를 기반으로 결정될 때
두 번째 상황이 특히 핵심이다. 이전 상태에 의존적인 복잡한 상태 변경은 setState의 개별적인 업데이트만으로는 안전하게 처리하기 힘들다.
실제 사례: NumberPad 컴포넌트
저희 사내 서비스의 금액 입력 패드(NumberPad) 큰 기능은 아래와 같은 요구사항을 가지고 있었다.
- 숫자 패드를 통한 금액 입력
- 천 단위 콤마 자동 포맷팅
- 최소 금액(1,000원) / 최대 금액(10억 원) 검증
- 빠른 금액 입력 버튼 (+1만, +5만, +10만, +100만)
- 입력 히스토리 관리
- 00으로 시작하는 입력 방지
언뜻 단순해 보이지만, 실제로는 상태 관리가 꽤나 복잡했다.
이를 useState로만 구현하면 무려 6개의 상태가 필요했다.
const [displayAmount, setDisplayAmount] = useState('0');
const [rawValue, setRawValue] = useState('');
const [isError, setIsError] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [history, setHistory] = useState<string[]>([]);
const [isFirstInput, setIsFirstInput] = useState(true);tsx문제점 1. 상태 간 동기화 지옥
한 번의 사용자 액션이 6개의 상태 업데이트를 일으켰다.
const handleNumberClick = (num: string) => {
let newRawValue;
if (isFirstInput) {
if (num === '0') return; // 0으로 시작 방지
newRawValue = num;
setIsFirstInput(false);
} else if (rawValue === '0') {
newRawValue = num; // 0 다음 숫자는 교체
} else {
if (rawValue.length >= 10) return;
newRawValue = rawValue + num;
}
setRawValue(newRawValue);
setDisplayAmount(formatWithComma(newRawValue));
const numValue = parseInt(newRawValue);
if (numValue > MAX_AMOUNT) {
setIsError(true);
setErrorMessage('최대 금액 초과');
return;
}
// 히스토리 업데이트 누락 위험
const formatted = formatWithComma(newRawValue);
if (history[history.length - 1] !== formatted) {
setHistory([...history, formatted]);
}
};tsx여기서 발생하는 문제는:
- 상태 업데이트 순서가 조금이라도 잘못되면 UI가 깨진다.
- 상태들이 서로 강하게 얽혀 있어서, 하나를 빠뜨리면 예기치 못한 버그가 터진다.
“displayAmount가 왜 이 값이지?”라는 질문에 답하려면 모든 이벤트 핸들러 코드를 뒤져야 한다.
문제점 2. 일관성 없는 상태 관리
비슷한 목적의 함수조차 상태 업데이트 순서가 들쭉날쭉했다.
const handleDelete = () => {
const newRawValue = rawValue.slice(0, -1);
if (!newRawValue) {
// 초기 상태로 리셋
setRawValue('');
setDisplayAmount('0');
setIsFirstInput(true);
setIsError(false);
setErrorMessage('');
} else {
// 한 글자 삭제
setRawValue(newRawValue);
setDisplayAmount(formatWithComma(newRawValue));
setIsError(false);
setErrorMessage('');
}
};
const handleClear = () => {
setDisplayAmount('0');
setRawValue('');
setIsError(false);
setErrorMessage('');
setIsFirstInput(true);
};tsx두 함수 모두 “초기화”라는 목적을 가지지만, 상태 업데이트 순서와 처리 방식이 다르다. 이 작은 차이가 버그를 만들어낸다.
이 문제를 useReducer로 해결한 방법은 useReducer로 일관성 문제 해결하기 섹션을 참고하자.
문제점 3. 복잡한 규칙 관리의 어려움
추가로 이런 상황도 있었다:
- 빠른 금액 버튼 처리의 복잡함
사용자가 입력한 금액을 교체해야 하는데, 개발자가 실수로 덧셈으로 구현하거나, 각종 검증 로직을 빠뜨리는 경우가 자주 발생한다.
6개의 상태를 동시에 업데이트해야 하는데,useState로는 하나라도 놓치기 쉽다.
// useState로 구현한 빠른 금액 버튼 - 여러 버그 포함
const handleQuickAmount = (amount: number) => {
// ❌ 버그 1: 덧셈 연산 (교체가 아닌 누적)
const newAmount = parseInt(rawValue || '0') + amount;
setRawValue(String(newAmount));
// ❌ 버그 2: 화면 표시 누락
// setDisplayAmount(formatWithComma(String(newAmount)));
if (newAmount > MAX_AMOUNT) {
setIsError(true);
setErrorMessage('최대 금액 초과');
}
// ❌ 버그 3: 히스토리 미기록
// ❌ 버그 4: 첫 입력 상태 미변경
};tsx이 코드의 문제점들:
-
버그 1: 빠른 금액 버튼(+1만, +5만 등)은 현재 금액을 “교체”해야 하는데 “덧셈”으로 구현되어 있다. 사용자가 “3,000원”을 입력한 상태에서 “+1만” 버튼을 누르면 “10,000원”이 되어야 하지만, 이 코드는 “13,000원”이 된다.
-
버그 2:
displayAmount업데이트가 누락되어 화면에 표시되는 금액과 실제 저장된 값이 달라집니다. -
버그 3: 입력 히스토리가 기록되지 않아 undo 기능이나 입력 추적이 불가능한다.
-
버그 4:
isFirstInput플래그가 업데이트되지 않아 이후 숫자 입력 시 예상치 못한 동작이 발생할 수 있다. -
00 입력 방지와 첫 입력 처리
첫 입력이 “00”일 때는 무시하지만, “1”을 입력한 후 “00” 버튼은 허용해야 한다.
더 심각한 문제는 개발자마다 이 로직을 다르게 구현한다는 점입니다. 어떤 곳에서는 “0” 입력을 막고, 어떤 곳에서는 허용하는 일관성 없는 동작이 발생했다.
이런 컨텍스트에 따른 규칙을useState로 관리하면, 매번 다른 조건문이 생기고 버그의 온상이 된다.
// useState로 구현 - 일관성 없는 00 처리
const handleDoubleZero = () => {
// 첫 입력이거나 0일 때 00 방지
if (isFirstInput || rawValue === '0') {
return;
}
// 길이 체크 (임의의 조건)
if (rawValue.length >= 9) {
return;
}
const newValue = rawValue + '00';
setRawValue(newValue);
setDisplayAmount(formatWithComma(newValue));
// ❌ isError, errorMessage, history, isFirstInput 업데이트 누락!
};
// 다른 핸들러에서는 또 다른 방식으로 처리 - 일관성 없음
const handleNumberClick = (num: string) => {
// 조건이 미묘하게 다름 (rawValue === '' vs isFirstInput)
if (num === '0' && (isFirstInput || rawValue === '')) {
return; // 단일 0도 막아버림
}
// ...
};tsxuseReducer로 해결하기
이럴 때는 useReducer가 답이다. dispatch로 “무엇을 할지(action)“만 전달하고, 상태 변경의 “어떻게(reducer)“는 한 곳에서 관리한다.
type NumberPadAction =
| { type: 'INPUT_NUMBER'; payload: string }
| { type: 'INPUT_DOUBLE_ZERO' }
| { type: 'DELETE_DIGIT' }
| { type: 'CLEAR' }
| { type: 'SET_QUICK_AMOUNT'; payload: number }
| { type: 'CONFIRM' };
interface NumberPadState {
displayAmount: string;
rawValue: string;
isError: boolean;
errorMessage: string;
history: string[];
isFirstInput: boolean;
}tsx리듀서는 모든 상태를 일관성 있게 갱신한다.
export const numberPadReducer = (
state: NumberPadState,
action: NumberPadAction
): NumberPadState => {
switch (action.type) {
case 'INPUT_NUMBER': {
const { rawValue, displayAmount, error } = validateAndFormat(
state.isFirstInput ? '' : state.rawValue,
action.payload
);
if (error) {
return { ...state, isError: true, errorMessage: error };
}
return {
...state,
rawValue,
displayAmount,
isFirstInput: false,
isError: false,
errorMessage: '',
history: [...state.history, displayAmount],
};
}
case 'SET_QUICK_AMOUNT': {
const rawValue = action.payload.toString();
const displayAmount = formatWithComma(rawValue);
return {
...state,
rawValue,
displayAmount,
isFirstInput: false,
isError: false,
errorMessage: '',
history: [...state.history, displayAmount],
};
}
case 'DELETE_DIGIT': {
const newRawValue = state.rawValue.slice(0, -1);
if (!newRawValue) {
return { ...initialState, history: state.history };
}
return {
...state,
rawValue: newRawValue,
displayAmount: formatWithComma(newRawValue),
isError: false,
errorMessage: '',
};
}
case 'CLEAR':
return { ...initialState, history: state.history };
default:
return state;
}
};tsx핸들러는 이제 간단해진다.
const [state, dispatch] = useReducer(numberPadReducer, initialState);
const handleNumberClick = (num: string) =>
dispatch({ type: 'INPUT_NUMBER', payload: num });
const handleClear = () => dispatch({ type: 'CLEAR' });tsx컴포넌트 코드 비교: useState vs useReducer
실제 컴포넌트에서 사용할 때의 차이를 보면 더욱 명확해진다.
useState를 사용한 컴포넌트 - 복잡하고 산만함:
function NumberPadComponent() {
const [displayAmount, setDisplayAmount] = useState('0');
const [rawValue, setRawValue] = useState('');
const [isError, setIsError] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [history, setHistory] = useState<string[]>([]);
const [isFirstInput, setIsFirstInput] = useState(true);
const handleNumberClick = (num: string) => {
// 30줄 이상의 복잡한 로직...
let newRawValue;
if (isFirstInput) {
if (num === '0') return;
newRawValue = num;
setIsFirstInput(false);
} else if (rawValue === '0') {
newRawValue = num;
} else {
if (rawValue.length >= 10) return;
newRawValue = rawValue + num;
}
setRawValue(newRawValue);
setDisplayAmount(formatWithComma(newRawValue));
const numValue = parseInt(newRawValue);
if (numValue > MAX_AMOUNT) {
setIsError(true);
setErrorMessage('최대 금액 초과');
return;
}
const formatted = formatWithComma(newRawValue);
if (history[history.length - 1] !== formatted) {
setHistory([...history, formatted]);
}
};
const handleQuickAmount = (amount: number) => {
// 또 다른 복잡한 로직...
setRawValue(amount.toString());
setDisplayAmount(formatWithComma(amount.toString()));
setIsFirstInput(false);
setIsError(false);
setErrorMessage('');
setHistory([...history, formatWithComma(amount.toString())]);
};
const handleDelete = () => {
// 삭제 로직도 복잡...
const newRawValue = rawValue.slice(0, -1);
if (!newRawValue) {
setRawValue('');
setDisplayAmount('0');
setIsFirstInput(true);
setIsError(false);
setErrorMessage('');
} else {
setRawValue(newRawValue);
setDisplayAmount(formatWithComma(newRawValue));
setIsError(false);
setErrorMessage('');
}
};
const handleClear = () => {
setDisplayAmount('0');
setRawValue('');
setIsError(false);
setErrorMessage('');
setIsFirstInput(true);
};
return (
<>
<Display value={displayAmount} error={isError} message={errorMessage} />
<NumberButtons onClick={handleNumberClick} />
<QuickAmountButtons onClick={handleQuickAmount} />
<ActionButtons onDelete={handleDelete} onClear={handleClear} />
</>
);
}tsxuseReducer를 사용한 컴포넌트 - 깔끔하고 명확함:
function NumberPadComponent() {
const [state, dispatch] = useReducer(numberPadReducer, initialState);
// 모든 핸들러가 한 줄로 끝!
const handleNumberClick = (num: string) =>
dispatch({ type: 'INPUT_NUMBER', payload: num });
const handleQuickAmount = (amount: number) =>
dispatch({ type: 'SET_QUICK_AMOUNT', payload: amount });
const handleDelete = () => dispatch({ type: 'DELETE_DIGIT' });
const handleClear = () => dispatch({ type: 'CLEAR' });
return (
<>
<Display
value={state.displayAmount}
error={state.isError}
message={state.errorMessage}
/>
<NumberButtons onClick={handleNumberClick} />
<QuickAmountButtons onClick={handleQuickAmount} />
<ActionButtons onDelete={handleDelete} onClear={handleClear} />
</>
);
}tsx차이점 요약:
| 항목 | useState | useReducer |
|---|---|---|
| 상태 선언 | 6개의 개별 상태 | 1개의 통합 상태 |
| 핸들러 코드 | 각각 10-30줄 | 모두 1줄 |
| 비즈니스 로직 위치 | 컴포넌트에 산재 | 리듀서에 집중 |
| 컴포넌트 크기 | 100줄 이상 | 30줄 이하 |
| 가독성 | 로직과 UI가 섞임 | UI에만 집중 가능 |
| 테스트 범위 | 컴포넌트 전체 테스트 필요 | 리듀서만 테스트하면 됨 |
이렇게 useReducer를 사용하면 컴포넌트는 “무엇을 보여줄지”에만 집중하고, “어떻게 상태를 변경할지”는 리듀서가 담당하게 된다. 이것이 바로 관심사의 분리(Separation of Concerns)다.
useReducer로 일관성 문제 해결하기
앞서 언급한 초기화 함수의 일관성 문제를 useReducer로 완벽하게 해결할 수 있다:
// 모든 초기화 로직이 리듀서 한 곳에 집중
case 'DELETE_DIGIT': {
const newRawValue = state.rawValue.slice(0, -1);
if (!newRawValue) {
// CLEAR와 동일한 방식으로 초기화 - 일관성 보장
return { ...initialState, history: state.history };
}
return {
...state,
rawValue: newRawValue,
displayAmount: formatWithComma(newRawValue),
isError: false,
errorMessage: ''
};
}
case 'CLEAR':
return { ...initialState, history: state.history };tsx이제 상태 업데이트 순서를 고민할 필요가 없다. 모든 상태가 한 번에, 일관된 방식으로 업데이트되기 때문이다.
useReducer로 얻은 이점
1. 예측 가능한 상태 변경
useState→ 여러 곳의 함수를 뒤져야 원인을 찾음useReducer→ 리듀서에서 해당 action만 보면 끝
2. 완벽한 타입 안정성
dispatch({ type: 'INPUT_NUMBER' }); // ❌ payload 필요
dispatch({ type: 'INPUT_NUMBER', payload: '1' }); // ✅ 정상
dispatch({ type: 'INVALID_ACTION' }); // ❌ 에러tsx3. 테스트 용이성
useState를 사용한 경우:
it('숫자 입력 시 천 단위 콤마 추가', () => {
const { result } = renderHook(() => useNumberPad());
act(() => {
result.current.handleNumberClick('1');
});
expect(result.current.displayAmount).toBe('1');
act(() => {
result.current.handleNumberClick('0');
result.current.handleNumberClick('0');
result.current.handleNumberClick('0');
});
expect(result.current.displayAmount).toBe('1,000');
// 상태 간 동기화 검증이 복잡함
expect(result.current.rawValue).toBe('1000');
expect(result.current.isError).toBe(false);
expect(result.current.history).toContain('1,000');
});tsxuseReducer를 사용한 경우:
it('숫자 입력 시 천 단위 콤마 추가', () => {
let state = initialState;
// 1 입력
state = numberPadReducer(state, {
type: 'INPUT_NUMBER',
payload: '1',
});
expect(state.displayAmount).toBe('1');
expect(state.rawValue).toBe('1');
// 000 입력하여 1,000 만들기
['0', '0', '0'].forEach((digit) => {
state = numberPadReducer(state, {
type: 'INPUT_NUMBER',
payload: digit,
});
});
// 모든 상태가 자동으로 동기화됨
expect(state).toEqual({
displayAmount: '1,000',
rawValue: '1000',
isError: false,
errorMessage: '',
history: ['1', '10', '100', '1,000'],
isFirstInput: false,
});
});tsx리듀서는 순수 함수이므로 React 환경 없이도 테스트 가능하며, 모든 상태 변경이 한 곳에서 일어나므로 검증이 간단하다.
4. 디버깅과 유지보수성
React 18부터는 이벤트 핸들러 내의 여러 setState 호출이 자동으로 배칭되므로 성능 차이는 크지 않다. 하지만 useReducer의 진짜 강점은 다른 곳에 있다:
// useReducer는 모든 액션을 로깅하기 쉬움
const withLogging = (reducer: typeof numberPadReducer) => {
return (state: NumberPadState, action: NumberPadAction) => {
console.log('Previous State:', state);
console.log('Action:', action);
const nextState = reducer(state, action);
console.log('Next State:', nextState);
return nextState;
};
};
// 사용 예시
const [state, dispatch] = useReducer(
withLogging(numberPadReducer),
initialState
);tsx콘솔 출력 예시 - 사용자가 “1”을 입력했을 때:
Previous State: {
displayAmount: '0',
rawValue: '',
isError: false,
errorMessage: '',
history: [],
isFirstInput: true
}
Action: {
type: 'INPUT_NUMBER',
payload: '1'
}
Next State: {
displayAmount: '1',
rawValue: '1',
isError: false,
errorMessage: '',
history: ['1'],
isFirstInput: false
}javascript이렇게 모든 상태 변화가 자동으로 추적되어 디버깅이 매우 쉬워진다.
- 명확한 액션 흐름: 어떤 액션이 언제 발생했는지 추적 가능
- 상태 변경 이력: 이전 상태로 되돌리기 쉬움
- 미들웨어 패턴: Redux처럼 로깅, 에러 리포팅 등 확장 가능
결론: 언제 useReducer를 선택해야 할까
판단 기준
useState를 사용해도 충분한 경우:
- 독립적인 단순 상태 (예: 토글, 카운터)
- 상태 간 연관성이 없거나 적은 경우
- 비즈니스 로직이 단순한 경우
useReducer를 고려해야 하는 경우:
- 하나의 액션이 여러 상태를 동시에 변경해야 할 때
- 상태 변경 로직이 복잡하고 조건이 많을 때
- 상태가 서로 긴밀하게 연결되어 있을 때
- 테스트 코드 작성이 중요한 경우
실무에서의 교훈
useReducer는 코드의 일관성, 예측 가능성, 테스트 용이성을 높여준다.
특히 금융 서비스처럼 정확성이 중요한 도메인에서는 useReducer가 더 안전한 선택이다. 모든 상태 변경이 한 곳에서 관리되기 때문에, 버그를 찾고 수정하기가 훨씬 쉽다.
처음에는 useState로 빠르게 시작하고, 복잡도가 높아지면 useReducer로 리팩토링하는 것도 좋은 전략이다. 중요한 것은 개발 속도, 안정성, 유지보수성 사이의 균형이다. 무조건 완벽한 코드보다는 현재 상황에 맞는 최적의 선택이 필요하다.
더 자세한 설명은 영상으로
이 포스트에서 다룬 내용을 실제 코드와 함께 더 자세히 설명한 영상을 준비했다.
useState에서 useReducer로 리팩토링하는 과정을 라이브 코딩으로 보여준다.
영상이 도움이 되었다면 구독과 좋아요 부탁드린다.
핵심 정리
- useState의 한계:
- 복잡한 상태 구조에서 여러 하위 값 관리 어려움
- 상태 간 동기화 지옥 (6개 상태 → 6개 업데이트)
- 일관성 없는 상태 관리 (함수마다 다른 업데이트 순서)
- 복잡한 규칙 관리 어려움 (빠른 금액 버튼, 00 입력 방지 등)
- useReducer의 장점:
- 예측 가능한 상태 변경 (모든 로직이 리듀서에 집중)
- 완벽한 타입 안정성 (TypeScript와 함께 사용 시)
- 테스트 용이성 (순수 함수로 React 없이 테스트)
- 디버깅 편의성 (모든 액션 로깅 가능)
- 선택 기준:
- useState: 독립적 단순 상태, 연관성 적음, 단순 로직
- useReducer: 여러 상태 동시 변경, 복잡한 로직, 긴밀한 연결, 테스트 중요
- 핵심 패턴:
dispatch({ type, payload }): 무엇을 할지 전달reducer(state, action): 어떻게 할지 정의- 컴포넌트는 “무엇을 보여줄지”만, 리듀서는 “어떻게 상태를 변경할지”만
useReducer는 교통 정리와 같다. useState는 각자 알아서 길을 건너는 것이고 (여러 setState 호출), useReducer는 신호등에서 한 번에 모든 차량을 통제하는 것이다 (한 곳에서 모든 상태 변경). 복잡한 교차로일수록 신호등이 필수다.