Skip to content

React useReducer 완전 정복 - useState의 한계를 넘어 구조적인 상태 관리하기

Published: at 오전 10:48

우리는 React의 내부 메커니즘, 모범 사례, 디자인 패턴, 그리고 고급 개념들을 탐구합니다.

이 글들은 기본을 넘어 React가 내부적으로 어떻게 작동하는지 진정으로 이해하고자 하는 React 개발자들을 위해 작성되었습니다.

서론: 상태 관리가 복잡해질 때

상태 관리는 React 애플리케이션 개발의 근본적인 측면 중 하나입니다.

지난 시리즈 글에서 우리는 함수형 컴포넌트에서 지역 상태를 관리하는 가장 기본적인 방법인 useState 훅을 탐구했습니다.

하지만 컴포넌트가 복잡해지고 상태 로직이 정교해지면서, useState는 금세 그 한계를 드러내기 시작합니다.

바로 이 지점에서 useReducer 훅이 등장합니다.

이 훅은 복잡한 상태를 관리하기 위한 더 구조적인 접근 방식을 제공하며, 특히 상태 업데이트가 이전 상태에 의존하거나 상태의 다른 부분들이 서로 상호 의존적일 때 강력한 힘을 발휘합니다.

Redux 패턴에서 영감을 받은 useReducer는 비즈니스 로직이 더 엄격한 구성을 요구하는 시나리오에서 useState의 강력한 대안이 됩니다.

useState의 한계: 쇼핑 카트 예제

useReducer의 가치를 이해하기 위해, 먼저 useState의 한계를 구체적인 예제, 즉 이커머스 쇼핑 카트를 관리하는 경우를 통해 살펴보겠습니다.

만약 useState만으로 쇼핑 카트를 구현한다면, 코드는 대략 이런 모습일 것입니다.

function ShoppingCartWithUseState() {
  // 여러 상태들이 흩어져 있습니다.
  const [items, setItems] = useState([]);
  const [total, setTotal] = useState(0);
  const [itemCount, setItemCount] = useState(0);
  const [discount, setDiscount] = useState(0);
  const [isCheckingOut, setIsCheckingOut] = useState(false);
  // ...

  // 아이템을 추가하는 함수
  const addItem = product => {
    // ... 아이템 추가 로직 ...
    const newItems = updateItems(items, product);
    setItems(newItems);

    // 관련된 다른 상태들도 '수동으로' 모두 업데이트해야 합니다.
    const newTotal = calculateTotal(newItems, discount);
    const newItemCount = calculateItemCount(newItems);
    setTotal(newTotal);
    setItemCount(newItemCount);
  };

  // 아이템을 제거하는 함수
  const removeItem = productId => {
    // ... 아이템 제거 로직 ...
    const newItems = filterItems(items, productId);
    setItems(newItems);

    // 여기서도 관련된 모든 상태를 다시 계산하고 업데이트해야 합니다.
    const newTotal = calculateTotal(newItems, discount);
    const newItemCount = calculateItemCount(newItems);
    setTotal(newTotal);
    setItemCount(newItemCount);
  };

  // ... 기타 다른 함수들 (할인 적용, 결제 등)
}



이 코드에는 몇 가지 심각한 문제가 있습니다.

useReducer 이해하기: 지휘 센터 만들기

useReducer 훅은 상태 로직을 ‘리듀서(reducer)‘라고 불리는 순수 함수에 중앙 집중화하여 이러한 문제들을 해결합니다.

마치 복잡한 교통 시스템을 통제하는 ‘중앙 관제소’를 만드는 것과 같습니다.

useReducer란 무엇인가?

useReducer의 기본 구문은 다음과 같습니다.

const [state, dispatch] = useReducer(reducer, initialState);



리듀서 패턴

useReducer의 핵심은 리듀서 함수입니다.

이 함수는 (state, action) => newState라는 형태를 가지며, 반드시 ‘순수 함수’여야 합니다.

즉, 원본 상태를 직접 수정해서는 안 되며(불변성 원칙), 부수 효과가 없어야 합니다.

‘액션’은 일반적으로 수행할 작업의 종류를 나타내는 type 속성과, 필요한 데이터를 담는 payload 속성을 가진 객체입니다.

useReducer로 리팩터링하기: 쇼핑 카트 예제

이제 useReducer로 쇼핑 카트를 어떻게 리팩터링하는지 보겠습니다.

1단계: 타입과 초기 상태, 그리고 리듀서 정의

먼저 필요한 모든 타입과 초기 상태, 그리고 모든 로직을 담을 리듀서 함수를 정의합니다.

이들은 컴포넌트 외부에 위치할 수 있어, 로직과 뷰의 분리가 명확해집니다.

// 타입 정의
type CartState = { /* ... */ };
type CartAction =
  | { type: "ADD_ITEM"; payload: Product }
  | { type: "REMOVE_ITEM"; payload: { id: string } }
  // ... 기타 액션 타입

// 초기 상태
const initialState: CartState = { /* ... */ };

// 중앙 관제소: 리듀서 함수
function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "ADD_ITEM": {
      // ... 아이템 추가 로직 ...
      const updatedItems = /* ... */;
      // 관련된 모든 파생 상태를 여기서 한 번에 계산합니다.
      const newTotal = calculateTotal(updatedItems, state.discount);
      const newItemCount = calculateItemCount(updatedItems);
      // '새로운 상태' 객체를 반환합니다.
      return {
        ...state,
        items: updatedItems,
        total: newTotal,
        itemCount: newItemCount,
      };
    }
    case "REMOVE_ITEM": {
      // ... 아이템 제거 로직 ...
      const updatedItems = /* ... */;
      const newTotal = calculateTotal(updatedItems, state.discount);
      const newItemCount = calculateItemCount(updatedItems);
      return {
        ...state,
        items: updatedItems,
        total: newTotal,
        itemCount: newItemCount,
      };
    }
    // ... 다른 모든 케이스 ...
    default:
      return state;
  }
}



2단계: 컴포넌트에서 useReducer 사용하기

이제 컴포넌트는 훨씬 더 단순해집니다.

복잡한 상태 업데이트 로직 대신, 단지 어떤 일이 일어났는지를 설명하는 ‘액션’을 dispatch하기만 하면 됩니다.

// useReducer를 사용하는 컴포넌트
function ShoppingCartWithReducer() {
  const [state, dispatch] = useReducer(cartReducer, initialState);
  const { items, total, itemCount } = state;

  // 제품 추가
  const addToCart = (product: Product) => {
    // "ADD_ITEM 액션이 발생했어!"라고 보고합니다.
    dispatch({ type: "ADD_ITEM", payload: product });
  };

  // 제품 제거
  const removeFromCart = (id: string) => {
    dispatch({ type: "REMOVE_ITEM", payload: { id } });
  };

  // 결제 처리
  const checkout = async () => {
    dispatch({ type: "CHECKOUT_START" });
    try {
      await processPayment(items, total);
      dispatch({ type: "CHECKOUT_SUCCESS" });
    } catch (error) {
      dispatch({ type: "CHECKOUT_FAILURE", payload: { error: "Payment failed" } });
    }
  };

  // ... 컴포넌트 렌더링 ...
}



이 접근 방식의 장점은 명확합니다.

useReducer 모범 사례

useReducer를 더 효과적으로 사용하기 위한 몇 가지 팁이 있습니다.

  1. ‘TypeScript와 함께 사용하기’: ‘판별된 유니온(discriminated unions)‘을 사용하여 액션 타입을 정의하면, switch 문 내에서 TypeScript가 action.payload의 타입을 정확하게 추론하여 코드 안정성을 높여줍니다.

  2. ‘액션 생성자 만들기’: dispatch({ type: "ADD_ITEM", payload: product })처럼 매번 객체를 만드는 대신, dispatch(addItem(product))와 같이 호출할 수 있는 함수(액션 생성자)를 만들면 코드가 더 깔끔해지고 오류가 줄어듭니다.

  3. ‘리듀서 단순화하기’: 리듀서가 너무 비대해지는 것을 막기 위해, 복잡한 계산 로직은 별도의 유틸리티 함수로 추출하는 것이 좋습니다.

  4. useContext와 결합하기: useReduceruseContext를 결합하면, Redux와 유사한 전역 상태 관리 시스템을 직접 만들 수 있습니다.

    이를 통해 앱 전체에서 상태(state)와 dispatch 함수를 props drilling 없이 사용할 수 있습니다.

결론

useReducer 훅은 상태 로직이 복잡하고 비즈니스 지향적인 상황에 특히 적합한, 강력하고 구조적인 상태 관리 접근 방식을 나타냅니다.

상태 전환을 순수 리듀서 함수에 중앙 집중화함으로써, 여러 가지 주요 이점을 제공합니다.

useState가 간단한 경우에 선호되는 옵션으로 남아 있지만, 쇼핑 카트, 예약 시스템 또는 상태의 다른 부분들이 서로 상호 작용하는 다른 로직을 모델링해야 할 때 useReducer는 이상적인 해결책으로 부상합니다.

useReducer를 마스터하는 것은 복잡한 상태를 다루는 여러분의 능력을 한 단계 끌어올리고, 더 유지보수하기 쉽고, 예측 가능하며, 더 잘 구조화된 React 애플리케이션을 구축할 수 있게 해줄 것입니다.