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
두 함수 모두 “초기화”라는 목적을 가지지만, 상태 업데이트 순서와 처리 방식이 다릅니다. 이 작은 차이가 버그를 만들어냅니다.
문제점 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도 막아버림
}
// ...
};
tsx
useReducer로 해결하기
이럴 때는 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} />
</>
);
}
tsx
useReducer를 사용한 컴포넌트 - 깔끔하고 명확함:
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' }); // ❌ 에러
tsx
3. 테스트 용이성
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');
});
tsx
useReducer를 사용한 경우:
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
로 리팩토링하는 과정을 라이브 코딩으로 보여드립니다.
영상이 도움이 되셨다면 구독과 좋아요 부탁드립니다! 🙏