프로그래밍/ReactJS

React의 강력한 상태 관리 도구: `useReducer` 훅 완벽 가이드

shimdh 2025. 10. 15. 09:13
728x90

안녕하세요, React 개발자 여러분! React를 사용하다 보면 상태 관리가 점점 복잡해지면서 코드가 엉망이 되는 경우가 많죠? 수많은 useState 훅으로 인해 컴포넌트가 길어지고, 버그가 생기기 쉽고, 팀원들과 공유할 때도 골치 아픈 경험, 다들 공감하시죠? 오늘은 이런 고민을 싹 날려줄 React의 숨겨진 보석, useReducer에 대해 깊이 파헤쳐 보겠습니다.

useReduceruseState의 강력한 대안으로, 복잡한 상태 로직을 체계적으로 관리할 수 있게 해줍니다. 특히 여러 하위 값들을 다루거나, 이전 상태에 따라 다음 상태가 결정되는 경우에 제격이죠. 이 가이드에서는 useReducer의 기본 개념부터 실전 예시, 그리고 언제 써야 할지까지 완벽하게 다뤄보겠습니다. 초보자도, 중급자도 따라올 수 있도록 단계별로 설명하니, 끝까지 읽어보세요!

728x90

useReducer는 무엇인가요?

useReducer는 Redux에서 영감을 받은 상태 관리 훅으로, 액션(action) 기반의 상태 전이(state transition) 를 컴포넌트 수준에서 처리합니다. Redux처럼 전역 상태를 관리하는 게 아니라, 개별 컴포넌트 안에서 작동한다는 점이 핵심 차이예요. 이 덕분에 불필요한 보일러플레이트 없이 복잡한 로직을 깔끔하게 다룰 수 있습니다.

useReducer를 써야 할까요? 다음 시나리오에서 빛을 발합니다:

  • 복잡한 상태 로직: 여러 useState로 흩어진 상태를 하나의 리듀서로 통합하면 코드가 훨씬 읽기 쉬워집니다.
  • 의존적인 상태 업데이트: 이전 상태를 기반으로 다음 상태를 계산할 때 (예: 카운터 누적, 폼 유효성 검사), 예측 가능성을 높여줍니다.
  • 관련 상태 캡슐화: 비즈니스 로직을 리듀서에 모아두면 관심사 분리가 쉬워지고, 테스트도 간단해집니다.

간단히 말해, useState는 "간단한 값 하나"에, useReducer는 "복잡한 객체"에 최적화된 도구예요. 이제 기본 문법부터 살펴보죠!

useReducer 기본 문법 파헤치기

useReducer의 사용법은 직관적입니다. 기본 형태는 다음과 같아요:

const [state, dispatch] = useReducer(reducerFunction, initialState);
  • state: 현재 상태 객체. UI 렌더링에 사용합니다.
  • dispatch: 액션 객체를 전달해 상태를 업데이트하는 함수. 호출 시 리듀서가 실행됩니다.
  • reducerFunction: 순수 함수(pure function)로, (state, action)을 받아 새로운 상태를 반환합니다. 중요: 기존 상태를 절대 직접 변경하지 말고, 항상 새 객체를 반환하세요! (React의 불변성 원칙)
  • initialState: 초기 상태 값. 컴포넌트 마운트 시 한 번만 사용됩니다.

리듀서 함수 생성: 핵심 로직 구현하기

리듀서는 useReducer의 심장부예요. 보통 switch 문으로 액션 타입을 처리합니다. 예를 들어, 간단한 카운터 리듀서:

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:
      return state; // 기본: 상태 변경 없음 (에러 대신 안전하게)
  }
}

여기서 액션 객체는 { type: 'increment' }처럼 생겼고, 필요 시 페이로드(payload)를 추가할 수 있어요 (예: { type: 'setValue', payload: 10 }). 리듀서는 입력에 따라 항상 같은 출력을 내야 하니, 외부 API 호출 같은 부수 효과는 피하세요.

실용적인 예시: 카운터 구현하기

이론만으로는 와닿지 않죠? 간단한 카운터부터 시작해 보세요. 이 예시는 useReducer의 기본 흐름을 보여줍니다.

import React, { useReducer } from 'react';

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

// 2. 리듀서 함수
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return initialState; // 초기 상태로 리셋
    default:
      return state;
  }
}

// 3. 컴포넌트
function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div style={{ textAlign: 'center', padding: '20px' }}>
      <h2>카운터: {state.count}</h2>
      <button onClick={() => dispatch({ type: 'increment' })} style={{ margin: '5px' }}>
        증가 (+)
      </button>
      <button onClick={() => dispatch({ type: 'decrement' })} style={{ margin: '5px' }}>
        감소 (-)
      </button>
      <button onClick={() => dispatch({ type: 'reset' })} style={{ margin: '5px' }}>
        리셋
      </button>
    </div>
  );
}

export default Counter;

이 코드에서:

  • dispatch 호출 → 리듀서 실행 → 새 상태 반환 → React 재렌더링.
  • reset 액션을 추가해 더 유연하게 만들었어요. 실제로 브라우저에서 돌려보니? 상태 변화가 직관적입니다!

고급 예시: TODO 리스트로 확장하기

카운터가 기본이라면, TODO 리스트로 복잡성을 더해 보죠. 여러 상태(아이템 배열, 필터)를 하나의 리듀서로 관리합니다.

import React, { useReducer, useState } from 'react';

const initialState = {
  todos: [],
  filter: 'all' // 'all', 'active', 'completed'
};

function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, { id: Date.now(), text: action.payload, completed: false }]
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo
        )
      };
    case 'SET_FILTER':
      return { ...state, filter: action.payload };
    default:
      return state;
  }
}

function TodoApp() {
  const [state, dispatch] = useReducer(todoReducer, initialState);
  const [input, setInput] = useState(''); // 입력은 useState로 간단히

  const addTodo = () => {
    if (input.trim()) {
      dispatch({ type: 'ADD_TODO', payload: input });
      setInput('');
    }
  };

  const filteredTodos = state.todos.filter(todo => {
    if (state.filter === 'active') return !todo.completed;
    if (state.filter === 'completed') return todo.completed;
    return true;
  });

  return (
    <div style={{ maxWidth: '400px', margin: '0 auto', padding: '20px' }}>
      <h2>TODO 리스트</h2>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="새 TODO 추가"
        onKeyPress={(e) => e.key === 'Enter' && addTodo()}
      />
      <button onClick={addTodo}>추가</button>

      <div>
        <button onClick={() => dispatch({ type: 'SET_FILTER', payload: 'all' })}>전체</button>
        <button onClick={() => dispatch({ type: 'SET_FILTER', payload: 'active' })}>진행 중</button>
        <button onClick={() => dispatch({ type: 'SET_FILTER', payload: 'completed' })}>완료</button>
      </div>

      <ul>
        {filteredTodos.map(todo => (
          <li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
            {todo.text}
            <button onClick={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })}>
              {todo.completed ? '미완료' : '완료'}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default TodoApp;

이 예시에서 보듯, 배열 조작(map, filter)과 객체 업데이트를 리듀서 하나로 처리하니 코드가 깔끔해집니다. 페이로드로 동적 데이터를 전달하는 점도 유용하죠!

useReducer는 언제 사용해야 가장 효과적일까?

useState vs useReducer: 둘 다 훌륭하지만, 상황에 따라 선택하세요. useReducer가 빛나는 경우는:

  1. 복잡한 상태 로직:
    • 상태가 객체/배열 형태로 여러 값을 가질 때.
    • 여러 setState 호출로 인한 업데이트 순서 문제가 발생할 때.
    • : 3개 이상의 관련 상태라면 리듀서로 통합!
  2. 의존적인 상태 업데이트:
    • 이전 상태를 참조해 계산 (예: 폼 에러 누적, 애니메이션 상태 전환).
    • 예측 가능성: 리듀서 덕에 디버깅이 쉬워집니다.
  3. 성능 최적화:
    • 대규모 앱에서 불필요한 재렌더링이 문제일 때.
    • useContext와 결합해 전역 상태처럼 사용하면 더 강력 (하지만 Redux만큼 과도하지 않게).
  4. 관심사 분리 & 테스트 용이성:
    • 로직을 컴포넌트 밖으로 빼서 단위 테스트가 간단.
    • 테스트 예: expect(reducer(initialState, { type: 'increment' })).toEqual({ count: 1 });

반대로, 단순한 boolean이나 숫자 하나라면 useState로 충분해요. 과도한 리듀서는 오히려 복잡성을 더할 수 있으니 균형이 중요합니다.

결론: useReducer로 상태 관리를 업그레이드하세요!

useReducer는 React의 상태 관리를 한 단계 끌어올리는 마법 같은 훅입니다. Redux의 철학을 빌려오면서도 컴포넌트 수준의 가벼움으로, 복잡한 UI 로직을 예측 가능하고 유지보수 쉽게 만들어줍니다. 오늘 예시처럼 직접 구현해 보시면, useState만 쓰던 습관이 싹 바뀔 거예요!

728x90