Part 2. 리액트 동작 원리와 상태 관리
24

useState vs useReducer: 상태가 복잡해지는 순간 선택해야 할 도구

useState vs useReducer: 상태가 복잡해지는 순간 선택해야 할 도구

크리스가 쇼핑몰의 장바구니 기능을 만들고 있다. 처음에는 단순했다. 상품 목록을 담을 배열 하나면 충분했다.

const [cart, setCart] = useState<Product[]>([]);

그런데 요구사항이 늘어나기 시작한다.

  • "장바구니에 담을 때 로딩 스피너를 보여주세요." (isLoading)
  • "에러가 나면 에러 메시지를 띄워주세요." (error)
  • "총금액도 실시간으로 계산해서 보여주세요." (totalAmount)
  • "할인 쿠폰이 적용되면 금액을 다시 계산해야 합니다." (discount)
  • 어느새 크리스의 컴포넌트는 useState로 도배가 되고, 상태를 업데이트하는 코드는 여기저기 흩어져버렸다. 로딩을 끄는 것을 깜빡하거나, 에러 초기화를 놓치는 버그가 속출한다.

    "상태 관리가 왜 이렇게 복잡하지? 한곳에서 모아서 처리할 수는 없을까?"

    이때가 바로 useReducer를 꺼내 들어야 할 순간이다.

    1. useState의 한계: 흩어진 로직

    useState는 독립적인 값(숫자, 문자열, 단순 불리언)을 다룰 때 가장 좋다. 하지만 여러 상태가 서로 의존적으로 변할 때는 문제가 생긴다.

    // ❌ 나쁜 예시: 상태 로직이 이벤트 핸들러 안에 숨어있다.
    const handleAddProduct = (product) => {
      setIsLoading(true);
      setError(null);
      
      api.addToCart(product)
        .then((newCart) => {
          setCart(newCart);
          setTotal(calculateTotal(newCart)); // 총액 계산 로직이 여기에도 있고...
          setIsLoading(false);
        })
        .catch((err) => {
          setError(err.message);
          setIsLoading(false);
        });
    };
    
    const handleDeleteProduct = (id) => {
      // 삭제할 때도 총액 계산 로직을 또 작성해야 함 (중복!)
      // ...
    };

    "무엇을 어떻게 바꿀지"에 대한 로직이 컴포넌트 내부 함수들에 파편화되어 있다. 코드를 읽는 사람은 setCart를 찾는 숨바꼭질을 해야 한다.

    2. useReducer: 상태 관리의 중앙 통제실

    useReducer는 상태 업데이트 로직을 컴포넌트 밖으로 분리해주는 도구다. 쉽게 말해 "회계 장부" 시스템과 같다.

  • 컴포넌트(직원): "금고에서 돈을 직접 꺼내지 마세요. 대신 '전표(Action)'를 써서 제출하세요."
  • 리듀서(회계사): "전표를 받으면, 정해진 규칙대로 장부(State)를 갱신합니다."
  • 이제 크리스의 장바구니 코드를 useReducer로 리팩토링 해보자.

    1단계: Action과 State 정의 (TypeScript)

    먼저 "무슨 일이 일어날 수 있는지"를 정의한다.

    // types.ts
    type CartState = {
      cart: Product[];
      total: number;
      isLoading: boolean;
      error: string | null;
    };
    
    // 발생할 수 있는 모든 사건(Action)을 나열
    type CartAction = 
      | { type: 'ADD_START' }
      | { type: 'ADD_SUCCESS'; payload: Product[] }
      | { type: 'ADD_FAIL'; payload: string }
      | { type: 'DELETE_ITEM'; payload: number }; // id

    2단계: Reducer 함수 작성 (로직 집약)

    모든 상태 변경 로직이 이 함수 하나에 모인다.

    // cartReducer.ts
    function cartReducer(state: CartState, action: CartAction): CartState {
      switch (action.type) {
        case 'ADD_START':
          return { ...state, isLoading: true, error: null };
          
        case 'ADD_SUCCESS':
          const newCart = action.payload;
          return { 
            ...state, 
            isLoading: false, 
            cart: newCart,
            total: calculateTotal(newCart) // 계산 로직도 여기서 한 번만!
          };
          
        case 'ADD_FAIL':
          return { ...state, isLoading: false, error: action.payload };
          
        default:
          return state;
      }
    }

    3단계: 컴포넌트에서 사용 (Dispatch)

    이제 컴포넌트는 로직을 몰라도 된다. 단지 요청(dispatch)만 보내면 된다.

    // CartComponent.tsx
    function CartComponent() {
      const [state, dispatch] = useReducer(cartReducer, initialState);
    
      const handleAddProduct = (product) => {
        dispatch({ type: 'ADD_START' }); // "시작했습니다" 보고
        
        api.addToCart(product)
          .then(res => dispatch({ type: 'ADD_SUCCESS', payload: res }))
          .catch(err => dispatch({ type: 'ADD_FAIL', payload: err.message }));
      };
    
      return (
        <div>
          {state.isLoading && <Spinner />}
          {state.error && <ErrorMsg msg={state.error} />}
        </div>
      );
    }

    3. 언제 무엇을 써야 할까?

    그렇다면 무조건 useReducer가 좋을까? 아니다. 코드가 길어지는 단점이 있다. 명확한 기준을 세워보자.

    기준useStateuseReducer
    데이터 타입원시값 (number, string, boolean)복잡한 객체 (Object, Array)
    상태 개수1~2개의 독립적인 상태서로 연관된 여러 상태
    로직 위치이벤트 핸들러 내부컴포넌트 외부 (reducer 함수)
    의존성이전 상태에 의존적이지 않음다음 상태가 이전 상태에 강하게 의존함

    ✅ 크리스의 선택 기준

  • useState: 입력 폼의 inputValue, 모달의 isOpen 토글, 단순 카운터.
  • useReducer: 데이터 Fetching 상태 (loading, success, error 세트), 복잡한 폼 유효성 검사, 장바구니 로직.
  • 핵심 정리

  • 복잡성 관리: 상태 변경 로직이 여러 군데 흩어져 관리하기 힘들다면 useReducer로 한곳에 모으는 것을 고려한다.
  • 가독성: dispatch({ type: 'LOGIN_SUCCESS' })처럼 코드를 읽는 것만으로 "어떤 일이 일어났는지" 명확히 알 수 있다.
  • 테스트 용이성: Reducer는 순수 함수이므로, UI 없이 로직만 따로 떼어서 테스트하기 매우 쉽다.
  • 이제 상태를 관리하는 법을 알았으니, 사용자와 상호작용하는 폼(Form)을 다룰 차례다. 입력을 다루는 두 가지 방식, 당신은 어느 쪽인가?

    "제어 컴포넌트 vs 비제어 컴포넌트: React Hook Form이 렌더링을 줄이는 비결" 편에서 계속된다.


    🔗 참고 링크

  • React Docs - useReducer
  • Extracting State Logic into a Reducer