useState 대신 useReducer를 선택해야 하는 순간

useState 대신 useReducer를 선택해야 하는 순간

React 개발을 하다 보면 대부분의 상태 관리는 useState로 충분합니다. 하지만 프로젝트가 커지고 단순한 컴포넌트여도 내부의 상태가 점점 복잡해질수록, useState만으로는 관리가 어려워지는 순간이 옵니다.

useState 대신 useReducer를 선택해야 하는 순간
useState 대신 useReducer를 선택해야 하는 순간

오늘은 실제 사내 서비스 개발 중 겪었던 문제를 사례로 들어, 왜 useReducer가 필요한지 이야기해보겠습니다.


언제 useReducer가 필요한가?

useReducer를 사용해야 하는 시점은 명확합니다.

  1. 복잡한 상태 구조로 여러 하위 값들을 관리해야 할 때
  2. 새로운 상태 값이 이전 상태를 기반으로 결정될 때

두 번째 상황이 특히 핵심입니다. 이전 상태에 의존적인 복잡한 상태 변경은 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로 해결한 방법 보기


문제점 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

차이점 요약:

항목useStateuseReducer
상태 선언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로 리팩토링하는 과정을 라이브 코딩으로 보여드립니다.

영상이 도움이 되셨다면 구독과 좋아요 부탁드립니다! 🙏