2025-09-22 12:48

  • useReduceruseState의 대안으로, 여러 하위 값을 포함하는 복잡한 정적 로직을 관리하는 데 특화된 React 훅이다.

  • 상태(state)를 업데이트하는 로직을 컴포넌트 외부의 순수 함수(리듀서)로 분리하여 코드의 예측 가능성과 유지보수성을 높인다.

  • dispatch 함수와 action 객체를 통해 상태 변경의 ‘의도’를 명확하게 표현하며, useContext와 결합하여 강력한 상태 관리 패턴을 구현할 수 있다.

useState를 넘어 React 상태 관리 마스터하기 useReducer 완벽 핸드북

React 개발자라면 누구나 useState에 익숙할 것이다. 컴포넌트의 상태를 간편하게 관리할 수 있는 강력한 도구이기 때문이다. 하지만 애플리케이션이 복잡해지고 관리해야 할 상태가 많아지면, useState만으로는 한계에 부딪히는 순간이 온다. 여러 상태가 서로 얽혀있고, 하나의 액션이 여러 상태 값을 변경해야 할 때, 코드는 걷잡을 수 없이 복잡해지고 버그가 발생하기 쉬운 환경이 조성된다.

이러한 ‘상태 관리의 지옥’에서 우리를 구원해 줄 도구가 바로 useReducer이다. useReducer는 단순히 useState의 대체재가 아니다. 이는 상태 관리의 패러다임을 바꾸는, 보다 체계적이고 예측 가능한 접근법을 제시한다. 이 핸드북을 통해 useReducer가 왜 만들어졌는지, 그 내부 구조는 어떻게 되어 있으며, 언제 그리고 어떻게 사용해야 하는지에 대해 깊이 있게 탐구해 보자.

1. 탄생 배경 왜 useState만으로는 부족했나

useReducer의 필요성을 이해하려면 먼저 useState가 직면하는 문제점들을 살펴봐야 한다.

여러 상태의 연쇄적인 업데이트

간단한 예시로 쇼핑몰의 상품 수량 및 가격 관리 로직을 생각해 보자. 사용자가 수량을 늘리거나, 쿠폰을 적용하거나, 배송 옵션을 변경할 때마다 총 주문 금액이 바뀌어야 한다. 이를 useState로 구현하면 다음과 같은 코드가 나올 수 있다.

JavaScript

const [quantity, setQuantity] = useState(1);
const [coupon, setCoupon] = useState(null);
const [shippingFee, setShippingFee] = useState(3000);
const [totalPrice, setTotalPrice] = useState(0);

// 수량 변경 핸들러
const handleQuantityChange = (newQuantity) => {
  setQuantity(newQuantity);
  // 총 금액을 다시 계산하는 복잡한 로직...
  const newTotalPrice = calculatePrice(newQuantity, coupon, shippingFee);
  setTotalPrice(newTotalPrice);
};

// 쿠폰 적용 핸들러
const handleApplyCoupon = (newCoupon) => {
  setCoupon(newCoupon);
  // 총 금액을 다시 계산하는 복잡한 로직...
  const newTotalPrice = calculatePrice(quantity, newCoupon, shippingFee);
  setTotalPrice(newTotalPrice);
};

위 코드의 문제점은 명확하다.

  1. 로직의 분산: 상태를 업데이트하는 로직이 각 이벤트 핸들러에 흩어져 있다. totalPrice를 계산하는 로직이 여러 곳에서 중복되어 나타난다.

  2. 버그 발생 가능성: 새로운 상태(예: 할인 코드)가 추가될 때마다 관련된 모든 핸들러를 찾아 수정해야 한다. 하나라도 놓치면 애플리케이션의 상태가 일치하지 않는 버그가 발생한다.

  3. 예측의 어려움: 컴포넌트가 커질수록 어떤 상호작용이 어떤 상태 변화를 일으키는지 추적하기가 매우 어려워진다.

이처럼 여러 상태가 서로 의존하고, 하나의 액션이 여러 상태에 영향을 미치는 복잡한 시나리오에서 useState는 관리의 복잡성을 가중시킨다. React 팀은 이러한 문제를 해결하기 위해 상태 업데이트 로직을 한곳에 중앙화하고, 상태 변경 과정을 더 명확하게 표현할 수 있는 방법이 필요하다는 것을 인지했다. 그 해답이 바로 Redux와 같은 상태 관리 라이브러리의 핵심 패턴인 ‘리듀서(Reducer)’ 개념을 차용한 useReducer였다.

useReducer상태 변경 로직을 컴포넌트 밖으로 분리하여, 상태 업데이트를 예측 가능하고 일관된 방식으로 처리할 수 있게 해준다.


2. useReducer의 구조 해부하기

useReducer는 언뜻 보기에 useState보다 복잡해 보이지만, 그 구조를 이루는 각 요소를 이해하면 금방 익숙해질 수 있다. useReducer의 기본 시그니처는 다음과 같다.

JavaScript

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

각 요소를 하나씩 살펴보자.

1. reducer (리듀서 함수)

리듀서는 useReducer의 심장이다. 이 함수는 현재의 stateaction이라는 두 개의 인자를 받아, 새로운 state를 반환하는 순수 함수다.

  • 형태: (state, action) => newState

  • 순수 함수(Pure Function): 동일한 입력에 대해 항상 동일한 출력을 반환하며, 외부 상태를 변경하는 등의 부수 효과(Side Effect)가 없어야 한다. 이는 리듀서 로직을 테스트하기 매우 쉽게 만들어준다.

  • 역할: ‘어떻게’ 상태를 변경할지에 대한 모든 로직을 담고 있다. 컴포넌트는 단지 ‘무슨 일이 일어났는지’만 알려주고, 실제 상태 변경은 이 리듀서가 전담한다.

2. state (상태)

컴포넌트가 현재 가지고 있는 상태 값이다. useState에서 반환되는 상태 값과 동일한 역할을 한다. useReducer는 일반적으로 원시 값(숫자, 문자열 등)보다는 여러 값을 포함하는 객체나 배열 형태의 복잡한 상태를 다룰 때 더 유용하다.

3. dispatch (디스패치 함수)

dispatch는 상태 변경을 촉발시키는 유일한 방법이다. useStatesetState 함수와 비슷하지만, 새로운 상태 값을 직접 인자로 받는 대신, action 객체를 인자로 받는다.

  • 역할: 컴포넌트에서 “이런 일이 발생했으니 상태를 업데이트해줘”라는 신호를 리듀서에게 보내는 역할을 한다.

  • action 객체: dispatch에 전달되는 action은 일반적으로 다음과 같은 형태의 자바스크립트 객체다.

    • type: 어떤 종류의 상태 변경을 원하는지를 나타내는 문자열. (예: 'INCREMENT', 'ADD_TODO')

    • payload (선택 사항): 상태 변경에 필요한 추가 데이터를 담는다. (예: 새로 추가할 할 일의 내용)

4. initialArg & init (초기 상태 설정)

  • initialArg: 상태의 초기값을 의미한다.

  • init (Lazy initialization): 초기 상태를 동적으로 계산해야 하는 경우 사용하는 선택적 함수다. init(initialArg)를 실행한 결과가 실제 초기 상태가 된다. 이는 초기 상태를 계산하는 로직이 복잡할 때 성능을 최적화하는 데 도움이 된다.

이 구조를 비유하자면, 식당 주문 시스템과 같다.

  • state: 현재 주방의 요리 상태 (예: {주문 목록: [], 조리 중: ‘파스타’})

  • 컴포넌트 (손님): “피자 한 판 추가해주세요!”라고 외친다.

  • dispatch (웨이터): 손님의 요청을 주문서(action 객체: { type: 'ADD_ORDER', payload: '피자' })에 적어 주방에 전달한다.

  • reducer (주방장): 주문서를 보고 현재 요리 상태(state)를 확인한 뒤, 새로운 요리 상태(newState)를 결정하여 주방을 업데이트한다. (예: 주문 목록에 ‘피자’ 추가)

이처럼 각자의 역할이 명확히 분리되어 있어 시스템이 체계적으로 운영된다.


3. 실전 사용법 단계별 가이드

이제 useReducer를 실제 코드에서 어떻게 사용하는지 알아보자. 가장 고전적인 예제인 카운터부터 시작해서, useReducer의 진가가 드러나는 복잡한 폼(Form) 관리 예제까지 살펴보겠다.

예제 1: 기본 카운터 만들기

가장 먼저, useState로도 충분히 만들 수 있는 간단한 카운터를 useReducer로 구현해 보자. 이를 통해 useReducer의 기본 흐름을 익힐 수 있다.

1단계: 초기 상태와 리듀서 함수 정의

컴포넌트 외부에 상태 관리 로직을 분리하여 정의한다.

JavaScript

// 초기 상태
const initialState = { count: 0 };

// 리듀서 함수
// action.type에 따라 다른 상태 업데이트 로직을 수행
function reducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    case 'RESET':
      return { count: 0 };
    default:
      throw new Error('Unexpected action');
  }
}

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

정의한 reducerinitialStateuseReducer 훅에 전달한다.

JavaScript

import React, { useReducer } from 'react';

// 1단계에서 정의한 initialState와 reducer

function Counter() {
  // state와 dispatch 함수를 반환받음
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <h2>Count: {state.count}</h2>
      {/* 각 버튼 클릭 시, 정해진 type의 action을 dispatch */}
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>증가</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>감소</button>
      <button onClick={() => dispatch({ type: 'RESET' })}>초기화</button>
    </div>
  );
}

이 예제에서는 dispatch 함수가 { type: '...' } 형태의 action 객체를 리듀서에게 전달한다. 리듀서는 이 action.type을 보고 switch 문을 통해 어떤 로직을 실행할지 결정한 후, 새로운 상태 객체를 반환한다. React는 이 새로운 상태를 감지하고 컴포넌트를 다시 렌더링한다.

예제 2: 복잡한 입력 폼 관리하기

이제 useReducer가 왜 강력한지 보여주는 예제를 살펴보자. 여러 개의 입력 필드를 가진 회원가입 폼을 관리하는 상황이다.

JavaScript

import React, { useReducer } from 'react';

// 1. 초기 상태 정의
const initialFormState = {
  name: '',
  email: '',
  password: '',
  isTouched: {
    name: false,
    email: false,
    password: false,
  },
  errors: {},
};

// 2. 리듀서 함수 정의
function formReducer(state, action) {
  switch (action.type) {
    case 'CHANGE_INPUT':
      // payload로 field와 value를 받음
      return {
        ...state,
        [action.payload.field]: action.payload.value,
      };
    case 'BLUR_INPUT':
      // 포커스가 해제된 필드를 표시
      return {
        ...state,
        isTouched: {
          ...state.isTouched,
          [action.payload.field]: true,
        },
      };
    case 'VALIDATE':
      // 모든 필드에 대한 유효성 검사 로직 (예시)
      const newErrors = {};
      if (!state.name) newErrors.name = '이름을 입력해주세요.';
      if (!state.email.includes('@')) newErrors.email = '유효한 이메일을 입력해주세요.';
      if (state.password.length < 8) newErrors.password = '비밀번호는 8자 이상이어야 합니다.';
      return {
        ...state,
        errors: newErrors,
      };
    default:
      return state;
  }
}

function SignUpForm() {
  const [formState, dispatch] = useReducer(formReducer, initialFormState);

  // 3. 입력 변경 핸들러
  const handleChange = (e) => {
    dispatch({
      type: 'CHANGE_INPUT',
      payload: {
        field: e.target.name,
        value: e.target.value,
      },
    });
  };
  
  // 4. 포커스 아웃 핸들러
  const handleBlur = (e) => {
    dispatch({
      type: 'BLUR_INPUT',
      payload: {
        field: e.target.name
      }
    })
    // 포커스가 아웃될 때마다 유효성 검사 실행
    dispatch({ type: 'VALIDATE' });
  }

  const handleSubmit = (e) => {
    e.preventDefault();
    dispatch({ type: 'VALIDATE' });
    // 에러가 없으면 제출 로직 실행
    if (Object.keys(formState.errors).length === 0) {
        console.log("제출 성공:", formState);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>이름</label>
        <input name="name" value={formState.name} onChange={handleChange} onBlur={handleBlur} />
        {formState.isTouched.name && formState.errors.name && <p>{formState.errors.name}</p>}
      </div>
      <div>
        <label>이메일</label>
        <input name="email" value={formState.email} onChange={handleChange} onBlur={handleBlur} />
        {formState.isTouched.email && formState.errors.email && <p>{formState.errors.email}</p>}
      </div>
      <div>
        <label>비밀번호</label>
        <input type="password" name="password" value={formState.password} onChange={handleChange} onBlur={handleBlur} />
        {formState.isTouched.password && formState.errors.password && <p>{formState.errors.password}</p>}
      </div>
      <button type="submit">가입하기</button>
    </form>
  );
}

만약 이 로직을 useState로 구현했다면, useState를 4번(name, email, password, errors, isTouched…) 호출해야 했을 것이다. 또한, 유효성 검사 로직은 여러 이벤트 핸들러에 흩어져 관리가 어려웠을 것이다.

useReducer를 사용함으로써 얻는 이점은 다음과 같다.

  • 중앙화된 로직: formReducer 함수 하나가 폼과 관련된 모든 상태 변경(입력, 포커스 아웃, 유효성 검사)을 처리한다.

  • 명확한 액션: dispatch({ type: 'CHANGE_INPUT', ... }) 코드는 “사용자가 입력을 변경했다”는 ‘의도’를 명확하게 드러낸다.

  • 간결한 컴포넌트: 컴포넌트 내의 이벤트 핸들러는 어떤 actiondispatch할지만 결정하면 되므로 매우 간결해진다. 실제 로직은 모두 리듀서에 위임했다.


4. useState vs useReducer 언제 무엇을 선택할까

두 훅 모두 React의 상태 관리를 위한 공식 도구이다. 어느 하나가 절대적으로 우월하다기보다는, 상황에 맞는 적절한 도구를 선택하는 것이 중요하다.

특징useStateuseReducer
적합한 상태 유형원시 값 (number, string, boolean), 간단한 객체/배열복잡한 객체/배열, 여러 상태가 서로 연관된 경우
상태 업데이트 로직컴포넌트 내에서 setState를 직접 호출컴포넌트 외부의 reducer 함수로 분리
코드의 양 (Boilerplate)적음. 매우 간결상대적으로 많음 (리듀서, 액션 타입 정의 등)
테스트 용이성컴포넌트 렌더링과 결합되어 테스트가 까다로울 수 있음리듀서는 순수 함수이므로 로직 자체를 독립적으로 테스트하기 매우 쉬움
디버깅상태 변화의 원인을 추적하기 어려울 수 있음action을 로깅하면 상태가 어떻게 변했는지 명확하게 추적 가능
최적화콜백 함수를 props로 전달 시, 자식 컴포넌트의 불필요한 리렌더링 유발 가능dispatch 함수는 동일성이 보장되므로 props로 전달해도 자식 컴포넌트 리렌더링 방지에 유리

선택 가이드라인 💡

  • useState를 선택해야 할 때:

    • 관리할 상태가 단순한 숫자, 문자열, 불리언일 경우.

    • 상태들이 서로 독립적이며, 하나의 액션이 하나의 상태만 변경하는 경우.

    • 상태 업데이트 로직이 setCount(count + 1)처럼 매우 간단한 경우.

    • 규칙: 일단 useState로 시작하라.

  • useReducer로 리팩토링을 고려해야 할 때:

    • 하나의 컴포넌트에서 useState를 3~4번 이상 사용하고, 이 상태들이 논리적으로 연관되어 있을 때.

    • 다음 상태(next state)를 계산하는 로직이 복잡하고 이전 상태(previous state)에 의존성이 깊을 때.

    • 상태 관리 로직을 별도로 테스트하고 싶을 때.

    • 자식 컴포넌트에 상태를 변경하는 콜백을 여러 개 내려보내야 할 때 (이 경우 dispatch 하나만 내려보내는 것이 더 효율적).

결론적으로, “간단하면 useState, 복잡해지면 useReducer”라는 원칙을 기억하면 좋다.


5. 심화 패턴 및 최적화 기법

useReducer는 다른 React 훅이나 패턴과 결합될 때 그 진가를 발휘한다.

1. useReducer + useContext = 미니 Redux

애플리케이션의 여러 컴포넌트가 동일한 상태를 공유하고 업데이트해야 할 때, 우리는 보통 Redux나 MobX 같은 전역 상태 관리 라이브러리를 사용한다. 하지만 작은 규모의 앱에서는 이런 라이브러리가 과도하게 느껴질 수 있다.

이때 useReduceruseContext를 조합하면 ‘Props Drilling’(상태를 사용하지도 않는 중간 컴포넌트들을 거쳐 props를 계속 내려보내는 문제) 없이도 효과적인 상태 관리가 가능하다.

구현 순서:

  1. Context 생성: 상태(state)와 dispatch 함수를 담을 Context를 만든다.

  2. Provider 컴포넌트 생성: 최상위 컴포넌트에서 useReducer를 사용하여 statedispatch를 생성하고, 이를 Context Provider의 value로 하위 컴포넌트들에게 제공한다.

  3. 하위 컴포넌트에서 사용: 상태가 필요한 하위 컴포넌트에서는 useContext 훅을 사용하여 state를 읽거나 dispatch를 호출한다.

JavaScript

// 1. Context 생성 (TodoContext.js)
import { createContext } from 'react';
export const TodoContext = createContext();

// 2. Provider 컴포넌트 생성 (App.js)
import React, { useReducer, useContext } from 'react';
import { TodoContext } from './TodoContext';

const initialState = { todos: [] };
function todoReducer(state, action) { /* ... 리듀서 로직 ... */ }

function App() {
  const [state, dispatch] = useReducer(todoReducer, initialState);

  return (
    // state와 dispatch를 value로 제공
    <TodoContext.Provider value={{ state, dispatch }}>
      <TodoList />
      <AddTodoForm />
    </TodoContext.Provider>
  );
}

// 3. 하위 컴포넌트에서 사용 (TodoList.js)
function TodoList() {
  // useContext를 통해 전역 상태에 접근
  const { state } = useContext(TodoContext);

  return (
    <ul>
      {state.todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
    </ul>
  );
}

// 3. 하위 컴포넌트에서 사용 (AddTodoForm.js)
function AddTodoForm() {
    // useContext를 통해 dispatch 함수에 접근
    const { dispatch } = useContext(TodoContext);
    
    const handleSubmit = (text) => {
        dispatch({ type: 'ADD_TODO', payload: text });
    }
    /* ... */
}

이 패턴을 사용하면 Redux의 기본 개념(중앙 저장소, 리듀서, 디스패치)을 React 내장 기능만으로 구현할 수 있어, 외부 라이브러리 의존성을 줄이고 가볍게 전역 상태를 관리할 수 있다.

2. 성능 최적화: dispatch 함수 전달하기

부모 컴포넌트에서 상태를 변경하는 함수를 자식에게 props로 넘겨줄 때를 생각해 보자.

JavaScript

function Parent() {
  const [count, setCount] = useState(0);

  // Parent가 리렌더링될 때마다 새로운 함수가 생성됨
  const increment = () => setCount(c => c + 1);

  return <Child increment={increment} />;
}

Parent 컴포넌트가 리렌더링될 때마다 increment 함수는 새로 생성된다. 만약 Child 컴포넌트가 React.memo로 최적화되어 있더라도, props로 받는 increment 함수가 계속 바뀌기 때문에 불필요한 리렌더링이 발생한다. 물론 useCallback으로 이 함수를 메모이제이션 할 수 있지만, useReducer를 사용하면 이 문제가 더 간단하게 해결된다.

dispatch 함수는 컴포넌트의 생애주기 동안 그 정체성(identity)이 바뀌지 않는 것이 보장된다. 따라서 dispatch를 자식 컴포넌트에 그대로 넘겨주면, 부모가 리렌더링되더라도 자식은 props가 변경되지 않았다고 판단하여 불필요한 리렌더링을 피할 수 있다.

JavaScript

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

  // dispatch는 리렌더링 되어도 항상 같은 함수
  return <Child dispatch={dispatch} />;
}

// React.memo로 최적화된 자식 컴포넌트
const Child = React.memo(({ dispatch }) => {
  console.log("Child is rendering!");
  return <button onClick={() => dispatch({ type: 'INCREMENT' })}>증가</button>;
});

이러한 특성은 특히 상태 업데이트 로직을 깊숙한 자식 컴포넌트까지 전달해야 할 때 매우 유용한 최적화 기법이 된다.

결론: 상태 관리의 새로운 지평

useReducer는 단순히 useState의 복잡한 버전이 아니다. 이것은 컴포넌트의 상태 관리 로직을 분리, 중앙화, 예측 가능하게 만드는 강력한 도구이며, React 애플리케이션의 확장성과 유지보수성을 한 단계 끌어올리는 열쇠다.

처음에는 useState의 간결함이 더 매력적으로 보일 수 있다. 하지만 상태 로직이 조금이라도 복잡해지는 순간을 마주한다면, 주저하지 말고 useReducer로의 리팩토링을 시도해 보라. 상태 변경의 ‘의도’를 명확히 하고, 비즈니스 로직을 순수 함수로 분리하여 테스트하는 즐거움을 알게 될 것이다. useReducer를 자유자재로 다루게 되는 순간, 당신은 React 상태 관리를 진정으로 마스터했다고 말할 수 있을 것이다.