[React다루는기술] 19.Redux(리덕스) 개념


Redux 가장 많이 사용하는 리액트 상태 관리 라이브러리이다. 리덕스를 사용하면 컴포넌트의 상태 업데이트 관련 로직을 분리시켜서 더 호율적으로 관리할 수 있다. 특히 Redux 라이브러리는 전역 상태를 관리할 때 굉장히 효과적이며 대규모 애플리케이션에서 상태 관리를 보다 효율적이고 예측 가능하게 만들어준다.


1. Redux(리덕스)

리덕스(Redux)는 리액트에서만 사용 가능한게 아닌 자바스크립트 애플리케이션의 상태(state)를 관리하기 위한 상태 관리 라이브러리 중 하나이다.

  • Store: 프로젝트에 리덕스를 적용하기 위해 스토어를 만든다. 애플리케이션의 상태를 담고 있는 객체입니다. 리덕스에서는 하나의 애플리케이션에 하나의 스토어가 있습니다. 상태를 변경하는 유일한 방법은 액션을 디스패치(dispatch)하여 스토어에 전달하는 것이다.
  • Action: 액션은 상태 변경을 위해 발생하는 이벤트나 데이터입니다. 액션은 일반적으로 객체 형태로 { type: ‘ACTION_TYPE’, payload: data }로 정의되며, 액션 객체는 type필드를 반드시 가지고 있어야 한다.
  • Reducer: 리듀서는 액션에 따라 상태를 어떻게 변경할지 정의하는 함수이다. 즉, 변화를 일으키는 함수이다. 리듀서는 현재 상태와 액션을 받아 두값을 참고해서 새로운 상태를 반환한다. 애플리케이션의 모든 상태 변화는 리듀서를 통해 이루어진다.
  • Dispatch: 액션을 리듀서에 전달하여 상태를 변경하는 함수입니다. 스토어에 액션을 보내기 위해 사용된다.
  • Middleware: 액션을 디스패치한 후에 리듀서에 도달하기 전에 실행되는 코드입니다. 미들웨어는 주로 비동기 작업, 로깅, 라우팅 등을 처리하는 데 사용된다.



리덕스 규칙

  • 단일 스토어: 애플리케이션의 모든 상태는 하나의 자바스크립트 객체인 스토어에 저장되며, 이로 인해 상태가 예측 가능하고 쉽게 관리된다.
  • 읽기 전용 상태: 상태를 직접 수정할때 기존의 객체를 직접 수정하지 않고 새로운 객체를 생성해주는 개념으로 불변성 특징을 가진다.
  • 순수 함수인 리듀서로 상태를 변경: 리듀서는 현재 상태와 액션을 받아서 다음 상태를 반환하는 순수 함수여야 한다.
    • 리듀서 함수는 이전 상태와 액션 객체를 파라미터로 받는다.
    • 이전 상태는 건들지 않고 수정 된 새로운 상태 객체를 반환한다.
    • 순수 함수이기에 리듀서 함수는 똑같은 파라미터로 호출시에 똑같은 결과 값을 반환해야 한다.
  • 단방향 데이터 흐름: 데이터는 상위 컴포넌트에서 하위 컴포넌트로만 흐르며, 부모 컴포넌트에서 자식 컴포넌트로는 props를 통해 전달된다.



2. React Redux 시작하기

아래와 같이 redux를 설치해야한다.

$ yarn add redux react-redux


2-1. 리덕스 사용시 컴포넌트 구조

리액트 프로젝트에서 리덕스를 사용할때 가장 많이 사용하는 패턴은
프레젠테이셔널 컴포넌트와 컨테이너와 컴포넌트를 분리하는 것이다.

여기서 프레젠테이셔널 컴포넌트란 주로 상태 관리가 이루어지지 않고,
단순하게 props를 받아 와서 화면에 UI를 보여 주기만 하는 컴포넌트를 말한다.


actions, constants, reducers 구조

리덕스를 사용하는 앱을 구성하기 위해서는 일반적으로 actions, constants, reducers 라는 세 개의 디렉터리를 만들어서 App 구조를 구성합니다. 각 디렉터리는 다음과 같은 역할을 합니다:

  • actions: 액션 생성자 함수들을 포함합니다. 이 디렉터리는 Redux 액션을 생성하는 함수들을 담고 있습니다.
  • constants: 액션 타입과 같은 상수들을 정의합니다. 액션 타입이나 다른 상수들을 중복해서 사용할 때 이 디렉터리를 활용하여 중복을 제거할 수 있습니다.
  • reducers: 리듀서 함수들을 포함합니다. 이 디렉터리는 상태를 어떻게 변경할지에 대한 로직을 포함합니다.
redux-app/
  |- actions/
  |    |- actionTypes.js
  |    |- counterActions.js
  |- constants/
  |    |- ActionTypes.js
  |- reducers/
       |- counterReducer.js


Ducks 패턴 구조

Redux 애플리케이션에서 액션 타입, 액션 생성 함수, 리듀서 함수를 기능별로 한 개의 디렉터리 구조로 만드는 방식으로 Ducks 패턴이라고 한다.

module/
  |- actions.js
  |- reducers.js
  |- actionTypes.js (optional)


2-2. Ducks 패턴 리듀서 생성

/modules/counter.js

/**
 * actions.js:
 * 해당 모듈에서 발생할 수 있는 모든 액션 생성자 함수를 포함한다.
 * 이 파일에서는 액션 생성자 함수를 정의하고 내보낸다.
 */
// 액션 타입 정의
const INCREASE = "counter/INCREASE";
const DECREASE = "counter/DECREASE";

// 액션 생성자 함수 정의
export const increase = () => ({ type: INCREASE });
export const decrease = () => ({ type: DECREASE });

const initialState = {
  number: 0,
};

function counter(state = initialState, action) {
  switch (action.type) {
    case INCREASE:
      return {
        number: state.number + 1,
      };
    case DECREASE:
      return {
        number: state.number - 1,
      };
    default:
      return state;
  }
}

export default counter;


/modules/reducers.js

/**
 * reducers.js
 * 해당 모듈의 리듀서 함수를 포함하며, 이 파일에서는 초기 상태와 리듀서 함수를 정의하고 내보낸다.
 * 초기 상태 및 리듀서 함수 생성한다.
 */
const CHANGE_INPUT = "todos/CHANGE_INPUT";
const INSERT = "todos/INSERT";
const TOGGLE = "todos/TOGGLE";
const REMOVE = "todos/REMOVE";

export const changeInput = (input) => ({
  type: CHANGE_INPUT,
  input,
});

export const insert = (text) => ({
  type: INSERT,
  todo: {
    id: id++,
    text,
    done: false,
  },
});

export const toggle = (id) => ({
  type: TOGGLE,
  id,
});

export const remove = (id) => ({
  type: REMOVE,
  id,
});

// 초기 상태 정의
const initialState = {
  input: "",
  todos: [
    {
      id: 1,
      text: "개발 공부",
      done: true,
    },
    {
      id: 2,
      text: "영어 공부",
      done: false,
    },
    {
      id: 3,
      text: "운동 하기",
      done: true,
    },
  ],
};

// 리듀서 함수 정의
function todos(state = initialState, action) {
  switch (action.type) {
    case CHANGE_INPUT:
      return {
        ...state,
        input: action.input,
      };
    case INSERT:
      return {
        ...state,
        todos: state.todos.concat(action.todo),
      };
    case TOGGLE:
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.id ? { ...todo, done: !todo.done } : todo
        ),
      };
    case REMOVE:
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.id),
      };
    default:
      return state;
  }
}

export default todos;



2-3. Root Reducer

Root 리듀서는 Redux 애플리케이션에서 여러 개의 리듀서를 결합하여 하나의 리듀서로 만드는 것이다. 일반적으로 Redux에서는 단일 리듀서를 createStore 함수에 전달하지만, 애플리케이션이 커지고 복잡해질수록 단일 리듀서로는 관리하기 어려워집니다. 따라서 Root 리듀서를 사용하여 여러 개의 리듀서를 하나로 합치는 것이 일반적이다.

Root 리듀서는 Redux의 combineReducers 함수를 사용하여 작성됩니다. 이 함수는 각 리듀서를 받아들여서 하나의 리듀서로 결합합니다. Root 리듀서는 전체 상태 트리에 대한 각 리듀서의 역할을 결정한다.


modules/index.js

import { combineReducers } from "redux";
import counter from "./counter";
import todos from "./todos";

/**
 * Root 리듀서
 */
const rootReducer = combineReducers({
  counter,
  todos,
});

export default rootReducer;

위의 예제에서는 counter, todos를 하나의 Root 리듀서로 합쳐서 해당 rootReducer를 Redux createStore 함수에 전달하여 스토어를 생성할 수 있다.


2-4. Provider 컴포넌트로 리덕스 상태 전달

counter, todos 리듀서를 합친 rootReducer를 Redux createStore 함수에 전달한 store를 Provider 컴포넌트에 props로 전달한다.

React Redux에서 Provider는 리액트 애플리케이션에 Redux 스토어를 제공하는 래퍼 컴포넌트입니다. 이 컴포넌트는 Redux 스토어를 하위 컴포넌트에 전달하여 모든 하위 컴포넌트에서 Redux의 상태를 사용할 수 있도록 해준다.

이러한 구성을 통해 Redux 스토어를 애플리케이션에 제공하고, 모든 컴포넌트에서 Redux의 상태를 사용할 수 있게 됩니다.

/index.js

import React from "react";
import ReactDOM from "react-dom/client";
import { legacy_createStore as createStore } from "redux";
import { Provider } from "react-redux";
import { composeWithDevTools } from "redux-devtools-extension";
import "./index.css";
import App from "./App";
import rootReducer from "./modules";

const store = createStore(rootReducer, composeWithDevTools());

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <Provider store={store}>
    <App />
  </Provider>
);


Redux DevTools 설치

$ yarn add redux-devtools-extension



3. Container 컴포넌트 생성

컴포넌트와 리덕스 스토어에 접근하여 원하는 상태를 받아오고, 또 액션도 디스패치를 줄 차례입니다.
리덕스 스토어와 연동된 컴포넌트를 컨테이너 컴포넌트라고 부른다.

컴포넌트를 리덕스와 연동하려면 react-redux에서 제공하는 connect 함수를 사용해야 한다.

컨테이너 컴포넌트는 리액트 애플리케이션에서 상태 관리 및 데이터 흐름을 담당하는 컴포넌트입니다. 일반적으로 컨테이너 컴포넌트는 Redux나 Context API와 같은 상태 관리 라이브러리와 함께 사용되며, 애플리케이션의 상태를 관리하고 데이터를 전달합니다.

  • 상태 관리: 컨테이너 컴포넌트는 상태를 관리하고, 필요한 경우 상태를 업데이트하거나 변경된 상태를 하위 컴포넌트에 전달한다.
  • 데이터 전달: 컨테이너 컴포넌트는 하위 컴포넌트에 필요한 데이터를 제공합니다. 이를 통해 데이터 흐름을 관리하고, 컴포넌트 간의 결합도를 낮춘다.
  • 비즈니스 로직 처리: 컨테이너 컴포넌트는 비즈니스 로직을 처리하고, 필요한 경우 데이터를 가공하거나 API와 통신하여 데이터를 가져온다.
  • 하위 컴포넌트 관리: 컨테이너 컴포넌트는 하위 컴포넌트의 라이프사이클을 제어하고, 필요한 경우 상태를 업데이트하여 하위 컴포넌트를 제어한다.

일반적으로 컨테이너 컴포넌트는 Redux에서 connect 함수를 사용하여 생성됩니다. connect 함수는 컴포넌트를 Redux 스토어에 연결하여 상태를 주입하고, 필요한 액션 디스패치 함수를 전달한다. 이를 통해 컨테이너 컴포넌트는 Redux의 상태를 구독하고, 상태가 변경될 때마다 하위 컴포넌트에 변경된 데이터를 전달한다.

컨테이너 컴포넌트의 역할은 주로 상태 관리와 데이터 전달에 초점을 맞추며, UI를 포함한 Presentational 컴포넌트들을 조합하여 애플리케이션의 동작을 구성함으로서 컴포넌트의 역할을 명확히 분리하여 코드의 유지보수성을 향상시킨다.

connect(mapStateToProps, mapDispatchToProps)("연동할 컴포넌트")
  • mapStateToProps : 리덕스 스토어 안의 상태를 컴포넌트의 props로 넘겨주기 위해 설정하는 함수로 Redux의 상태를 React 컴포넌트의 props로 매핑한다. 이 경우 number라는 props를 Redux 스토어의 counter 상태에서 추출한다.
  • mapDispatchToProps : 액션 생성 함수를 컴포넌트의 props로 넘겨주기 위해 사용하는 함수로 Redux의 액션을 React 컴포넌트의 props로 매핑하고 이 경우 increase와 decrease라는 props를 정의하고, 각각의 함수 내에서 로직 처리
const makeContainer = connect(mapStateToProps, mapDispatchToProps)
makeContainer("연동할 컴포넌트")


/containers/CounterContainer.js

import { connect } from "react-redux";
import Counter from "../components/Counter";

const CounterContainer = ({ number, increase, decrease }) => {
  return (
    <Counter number={number} onIncrease={increase} onDecrease={decrease} />
  );
};

const mapStateToProps = (state) => ({
  number: state.counter.number,
});

const mapDispatchToProps = (dispatch) => ({
  increase: () => {
    console.log("# increase");
  },
  decrease: () => {
    console.log("# decrease");
  },
});

export default connect(mapStateToProps, mapDispatchToProps)(CounterContainer);


App.js

import CounterContainer from "./containers/CounterContainer";
import TodosContainer from "./containers/TodosContainer";

const App = () => {
  return (
    <div>
      <CounterContainer />
      <hr />
      <TodosContainer />
    </div>
  );
};

export default App;




react-deal-book-img