본문 바로가기
Front-End/React + Native

[React]useReducer와 useContext로 알아보는 전역 상태 관리

by MS_developer 2025. 2. 1.

출처: useReducer hook in React (No redux here)

 

이전의 포스팅을 통해 useState와 useReducer의 차이점과 언제 useReducer를 쓰는 것이 좋은지에 대해 알아보았다.

 

또한, 해당 포스팅에서 useReducer는 Redux와 원리가 비슷하지만 실제로는 그 차이가 있다고 언급했다.

 

Redux와 useReducer는 어떻게 다른지, 구현 방식에서 어떤 차이가 있는지 좀 더 자세하게 알아보자.

 


Redux와 useReducer의 차이점

 

React의 useReducer와 Redux는 모두 리듀서 패턴(reducer pattern)을 기반으로 한 상태(state) 관리 방식을 사용한다.

 

리듀서 패턴이랑 상태(state)를 변경하는 로직을 하나의 함수(reducer)에서 관리하는 방식을 의미하는데, 상태를 직접 변경하지 않고 액션(action)을 통해 상태를 업데이트하는 구조를 가지게 된다.

 

하지만 useReducer는 컴포넌트 내부에서 상태를 관리하는 반면, Redux는 전역 상태 관리를 위한 라이브러리라는 차이점이 존재한다.

 

두 개념의 유사한 점과 다른 점을 표로 요약하면 다음과 같다.

 

개념 useReducer Redux
상태 관리 방식 state와 dispatch(action)을 사용하여 상태를 변경
리듀서(reducer) 사용 (state, action) => newState 형태의 리듀서 함수 사용
불변성 유지 기존 상태를 직접 변경하지 않고, 새로운 상태를 반환
액션 기반 업데이트 dispatch({ type: "ACTION" }) 방식 사용
비동기 처리 직접 useEffect를 사용해야 함 redux-thunk, redux-saga 등을 활용하여 비동기 처리 가능
전역 상태 관리 컴포넌트 내부 상태만 관리 가능 여러 컴포넌트에서 공유하는 전역 상태 관리 가능

 

위 차이점을 인지한 상태로 useReducer와 useContext를 활용해 어떻게 Redux와 유사하게 구현할 수 있는지 알아보자.

 


useReducer + useContext

먼저 이전 포스팅에서 useReducer를 사용했던 예시 코드를 가져와 보았다.

 

import React, { useReducer } from "react";

const initialState = { count: 0 };

const 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:
      throw new Error("알 수 없는 액션 타입");
  }
};

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

  return (
    <div>
      <p>카운트: {state.count}</p>
      <button onClick={() => dispatch({ type: "INCREMENT" })}>증가</button>
      <button onClick={() => dispatch({ type: "DECREMENT" })}>감소</button>
      <button onClick={() => dispatch({ type: "RESET" })}>초기화</button>
    </div>
  );
};

export default Counter;

 

서로 관련있는 상태들을 종합적으로 관리하기 위한 코드로, dispatch를 통해 전달되는 타입을 기반으로 상태를 업데이트하고 있다.

 

이때 useContext를 활용해 전역 상태를 제공하기 위해 Context와 Provider 컴포넌트를 생성하는 코드를 추가한다.

 

import React, { useReducer, createContext, useContext } from "react";

const CounterContext = createContext();

const initialState = { count: 0 };

const reducer = (state, action) => {
	// ...
};

const CounterProvider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);
  
  return (
    <CounterContext.Provider value={{ state, dispatch }}>
      {children}
    </CounterContext.Provider>
  );
};

 

 

이후 기존의 Counter 컴포넌트에서 사용했던 useReducer 함수의 매개변수에 생성한 Context를 할당한다.

 

const Counter = () => {
  const { state, dispatch } = useContext(CounterContext);

  return (
    <div>
      <p>카운트: {state.count}</p>
      <button onClick={() => dispatch({ type: "INCREMENT" })}>증가</button>
      <button onClick={() => dispatch({ type: "DECREMENT" })}>감소</button>
      <button onClick={() => dispatch({ type: "RESET" })}>초기화</button>
    </div>
  );
};

 

최상위 컴포넌트에서 Provider를 사용하는 것도 잊지 말고 적용해 준다.

 

const App = () => (
  <CounterProvider>
    <Counter />
  </CounterProvider>
);

 

위와 같이 구현하면 간단한 코드로 별도의 Redux 라이브러리 설치 없이 전역 상태를 관리할 수 있다. 

 

useReducer + useContext의 단점

 

단, useReducer와 useContext로 구현했을 경우 비동기 처리 시에는 문제가 발생할 수 있다. 

 

const reducer = (state, action) => {
  switch (action.type) {
    case "FETCH_SUCCESS":
      return { ...state, data: action.payload, loading: false };
    case "FETCH_ERROR":
      return { ...state, error: action.payload, loading: false };
    default:
      return state;
  }
};

const fetchData = () => {
  dispatch({ type: "FETCH_START" });

  fetch("https://jsonplaceholder.typicode.com/posts/1")
    .then((response) => response.json())
    .then((data) => dispatch({ type: "FETCH_SUCCESS", payload: data }))
    .catch((error) => dispatch({ type: "FETCH_ERROR", payload: error.message }));
};

 

위 코드의 경우, fetchData()에서 API 호출이 비동기적으로 실행되지만, dispatch는 즉시 실행되므로 상태 흐름이 어색하다. 뿐만 아니라 useReducer 내부에서 fetch를 직접 호출했기 때문에 부작용(side-effect)이 발생할 수 있다.

 

이러한 경우 Redux의 redux-thunk처럼 비동기 처리를 위한 미들웨어(액션이 리듀서로 전달되기 전에 추가적인 작업을 수행하는 역할)가 필요한데, useReducer에는 이러한 내장 지원 기능이 없다.

 

이때 useEffect를 사용하면 해당 문제를 해결할 수 있다.

 

const fetchData = async () => {
  dispatch({ type: "FETCH_START" });

  try {
    const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
    const data = await response.json();
    dispatch({ type: "FETCH_SUCCESS", payload: data });
  } catch (error) {
    dispatch({ type: "FETCH_ERROR", payload: error.message });
  }
};

useEffect(() => {
  fetchData();
}, []);

 

하지만 useEffect를 활용하면 비동기 데이터를 안정적으로 가져올 수 있지만, Redux처럼 redux-thunk를 이용한 미들웨어 방식보다 사용 방식이 불편하다.

 

비동기 처리 외에도 useContext 사용으로 인한 리렌더링 시에도 문제가 발생할 수 있다.

 

useContext는 값이 변경될 때마다 모든 구독된 컴포넌트가 리렌더링되는데, 특정 상태만 변경되었더라도 useContext에서 값을 받아오는 모든 컴포넌트가 리렌더링되는 문제점이 있다.

 

const Counter = () => {
  const { state } = useContext(CounterContext);
  
  return <p>카운트: {state.count}</p>;
};

const ButtonPanel = () => {
  const { dispatch } = useContext(CounterContext);

  return (
    <div>
      <button onClick={() => dispatch({ type: "INCREMENT" })}>증가</button>
      <button onClick={() => dispatch({ type: "DECREMENT" })}>감소</button>
    </div>
  );
};

 

위 코드의 경우, Counter 컴포넌트는 state.count만 사용하지만, useContext를 통해 state를 구독하고 있어 모든 상태 변경 시 리렌더링 되고 있다. 비슷한 경우로 ButtonPanel 컴포넌트 역시 dispatch만 필요하지만, useContext를 통해 state를 함께 구독했기 때문에 불필요한 리렌더링이 발생하고 있다.

 

 

해당 문제를 해결하기 위해서는 useContext를 분리하여 최적화하여야 한다.

 

const CounterDisplay = () => {
  const count = useContext(CounterContext).state.count;
  return <p>카운트: {count}</p>;
};

const ButtonPanel = () => {
  const dispatch = useContext(CounterContext).dispatch;
  return (
    <div>
      <button onClick={() => dispatch({ type: "INCREMENT" })}>증가</button>
      <button onClick={() => dispatch({ type: "DECREMENT" })}>감소</button>
    </div>
  );
};

 

자연스럽게 이에 따라 코드가 길어지고 복잡해지게 된다.

 


Redux 사용 방법 및 장단점

 

Redux는 라이브러리를 설치해야 한다는 번거로움이 있지만, 앞선 구현 과정들을 좀 더 안전하게 진행할 수 있다. (설치 방법은 공식문서를 참고하여 진행하면 된다.)

 

단, store, reducer, action 파일들을 생성하고 설정해야 하기 때문에 Redux에 대한 별도의 공부가 필요하다. 만약 useReducer와 userContext 개념에 대해 몰랐다면 마찬가지로 공부가 필요하겠지만, 개인적으로 Redux가 더 복잡하다고 생각한다.

 

기존의 코드에서 추가할 코드를 짚어가며 Redux 사용 예시를 매우 간략하게 설명해 보겠다.

 

먼저 Redux 라이브러리를 설치한 후, Redux 리듀서와 Redux 스토어를 각각의 파일로 생성한다.

 

// reducers.js
const initialState = { count: 0 };

const counterReducer = (state = initialState, 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;
  }
};

export default counterReducer;

// store.js
import { createStore } from "redux";
import counterReducer from "./reducers";

const store = createStore(counterReducer);

export default store;

 

이후 Redux Provider를 최상단에 적용한다. 

 

import { Provider } from "react-redux";
import store from "./store";

const App = () => (
  <Provider store={store}>
    <Counter />
  </Provider>
);

 

기존의 Counter 컴포넌트에도 Redux 상태를 사용하도록 로직을 수정해주어야 한다.

 

import React from "react";
import { useSelector, useDispatch } from "react-redux";

const Counter = () => {
  const count = useSelector((state) => state.count);
  const dispatch = useDispatch();

  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={() => dispatch({ type: "INCREMENT" })}>증가</button>
      <button onClick={() => dispatch({ type: "DECREMENT" })}>감소</button>
      <button onClick={() => dispatch({ type: "RESET" })}>초기화</button>
    </div>
  );
};

export default Counter;

 

기존에 하나의 파일에서 대부분의 기능을 해결했던 것과 다르게, 확실히 Redux의 사용방법이 좀 더 복잡하게 느껴질 것 같다. 하지만 Redux를 사용하면 애플리케이션 전역 어디에서든 상태를 가져와 보다 강력한 전역 상태 관리가 가능해지고, 기존의 리렌더링 문제도 useSelector를 통해 필요한 상태 값만 구독하여 최적화가 가능하다.

 

이 외에도 Redux Devtools를 통한 디버깅이나 redux-thunk, redux-saga를 활용한 비동기 처리를 간편하게 할 수 있다.

 

라이브러리 설치를 통해 리소스를 투자할 가치가 충분히 있지만, 대규모 프로젝트가 아니라면 기존의 useReducer와 useContext 만으로 기능을 구현하는 것이 유용하다. 반대로 말하자면, 대규모 애플리케이션에서는 Redux를 사용하는 것이 유지보수적인 측면이나 비동기 처리, 리렌더링 최적화 등 관련된 요구 사항들을 충족시키기 더 용이하다.

 

만약 Redux의 초기 설정이나 사용 방법이 복잡하고 보일러플레이트 코드가 많다고 생각한다면, Zustand, MobX, Recoil, Jotai 등과 같은 보다 직관적이고 사용하기 쉬운 대체 라이브러리들이 있다. Redux는 그저 비교 대상일 뿐, 최근에는 오히려 보다 가벼운 대체 라이브러리들을 선호하는 추세라고 생각한다.

 

진행 중인 프로젝트의 규모와 성격에 따라, 그리고 클라이언트의 요구 사항에 따라 적절한 라이브러리를 대체하여 사용한다면 작고 간단한 프로젝트라도 useReducer와 useContext를 사용하는 것보다는 보다 체계적이고 안전하게 상태를 관리할 수 있을 것이다.

 

하지만 왜 useReducer와 useContext를 사용할 수 있음에도 라이브러리를 설치하여 사용하는 것이 고려되는지, 그리고 어떤 식으로 구동하는지 알고 있어야 라이브러리를 선택하는 과정에서 단순히 "인기가 있어서"라는 이유보다 좀 더 구체적이고 논리적인 근거를 들 수 있을 것이다.

 

프로젝트에서 사용되는 라이브러리의 근거가 무엇인지, 왜 사용하게 되었는지, 어떤 기능들이 있는지를 알고 있어야 해당 라이브러리를 효율적으로 프로젝트에서 올바르게 사용할 수 있을 것이다.


번외

 

막간으로 한 가지 궁금한 점이 있어 찾아보았다.

 

Redux가 먼저인가, useReducer가 먼저인가?

 

 

리액트 역사에 익숙한 개발자라면 알고 있겠지만, Redux가 useReducer보다 먼저 등장했다.

 

2015년에 Redux가 먼저 등장했고, dispatch(action) → reducer(state, action) → newState 패턴을 사용했다. 이후 2018년에 React 16.8 버전에서 useReducer가 추가되었다. (useState 사용 방식이 변환된 것도 같은 버전이다.)


참조

댓글