728x90
728x90

리덕스(Redux), 리덕스 툴킷(Redux Toolkit)

들어가며

  • 리액트(React.js)의 전역 상태 관리 라이브러리 중의 하나인 리덕스(Redux)리덕스 툴킷(Redux Toolkit)에 대해 정리해본다.

 

 

리덕스(Redux)

개념

  • 자바스크립트 애플리케이션에서 상태 관리를 위해 사용되는 상태 컨테이너
  • 애플리케이션의 상태를 중앙에서 관리함으로써 상태 변화를 쉽게 추적하고, 디버깅을 단순화하며, 다양한 컴포넌트 간에 상태를 공유하는 것을 쉽게 해준다.
  • 리덕스를 사용하기 위해서는 아래의 명령을 실행하여 관련 패키지를 설치해준다.
$ yarn add redux           # npm install redux
$ yarn add react-redux     # npm install react-redux

 

 

리덕스(Redux)는 리액트(React.js) 뿐만 아니라 다양한 자바스크립트 패키지에서도 사용할 수 있다.

 

 

 

액션(Action)

  • 애플리케이션에서 일어나는 사건을 설명하는 객체
  • @type@ 속성을 필수로 가지며, 상태 변경을 트리거하는 역할을 한다.
  • 예) @{ type: 'INCREMENT' }@, @{ type: 'ADD_TODO', text: 'Learn Redux' }@

 

리듀서(Reducer)

  • 액션을 처리하여 새로운 상태를 반환하는 함수
  • 이전 상태 액션 객체를 인자로 받아 새로운 상태 객체를 반환한다.
  • 순수 함수여야 하며, 입력이 같으면 출력도 항상 같아야 한다.
    • 순수 함수(Pure Function)
      • 동일한 인자가 주어졌을 때 항상 동일한 결과를 변환
      • 함수의 실행이 외부 상태에 의존하지 않음
      • 외부 상태를 변경하지 않는 함수
function counter(state = 0, action) {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    default:
      return state;
  }
}

 

스토어(Store)

  • 애플리케이션의 상태를 담고 있는 객체
  • @createStore@ 함수를 사용하여 스토어를 생성한다.
  • 스토어는 3가지 메소드를 가진다:
    • @getState()@: 현재 상태 반환합
    • @dispatch(action)@: 상태를 변경하는 액션 보내기
    • @subscribe(listener)@: 상태가 변경될 때마다 호출되는 리스너를 등록하기

 

미들웨어(Middleware)

  • 액션이 리듀서에 도달하기 전에 처리할 수 있는 기능을 제공한다.
  • 로깅, 에러 보고, 비동기 작업 처리 등에 사용된다.

 

 

사용 방법

  • 예제 코드와 함께 Redux를 사용하는 방법을 간단하게 정리해본다.

 

./src/store/index.js
  • 프로젝트의 @src@ 폴더 내부에 @store@ 폴더를 생성한 후, 안에 @index.js@ 파일을 생성한다.
import { createStore } from 'redux';

const counterReducer = (state = initialState, action) => {
  if (action.type === "increment") {
    return {
      counter: state.counter + 1,
      showCounter: state.showCounter,
    };
  }

  if (action.type === "increase") {
    return {
      counter: state.counter + action.amount,
      showCounter: state.showCounter,
    };
  }

  if (action.type === "decrement") {
    return {
      counter: state.counter - 1,
      showCounter: state.showCounter,
    };
  }

  if (action.type === "toggle") {
    return {
      showCounter: !state.showCounter,
      counter: state.counter,
    };
  }

  return state;
};

// 중앙 스토어 생성 및 리듀서 함수 연결
const store = createStore(counterReducer);

export default store;

 

./src/index.js
  • 생성한 @store.js@ 파일에서 @store@를 불러온다.
  • @react-redux@ 패키지에서 @Provider@ 컴포넌트를 불러온다.
  • @Provider@ 컴포넌트로 @<App />@ 컴포넌트를 감싸준다.
    • @store@ prop의 값을 @store.js@에서 불러온 @store@로 설정해준다.
import React from "react";
import ReactDOM from "react-dom/client";

import "./index.css";
import App from "./App";

// Provider, store 불러오기
import { Provider } from "react-redux";
import store from "./store/index";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <Provider store={store}>    {/* <App/>을 <Provider>로 감싸준 후, store prop 설정 *}
    <App />
  </Provider>
);

 

./src/components/counter.js
  • 실제 컴포넌트에서 사용하는 예제이다.
  • @useSelecter@와 전역 상태 보관소(@store.js@)의 값에 접근한다.
  • @useDispatch@를 사용하여 컴포넌트에서 전역 상태 보관소(@store.js@)에 액션(Action)을 전달하여 상태를 변경한다.
import { useSelector, useDispatch } from "react-redux";

import classes from "./Counter.module.css";

const Counter = () => {
  const dispatch = useDispatch();
  const counter = useSelector((state) => state.counter);
  const show = useSelector((state) => state.showCounter);

  const incrementHandler = () => {
    dispatch({ type: "increment" });
  };

  const increaseHandler = () => {
    dispatch({ type: "increase", amount: 5 });
  };

  const decrementHandler = () => {
    dispatch({ type: "decrement" });
  };

  const toggleCounterHandler = () => {
    dispatch({ type: "toggle" });
  };

  return (
    <main className={classes.counter}>
      <h1>Redux Counter</h1>
      {show && <div className={classes.value}>{counter}</div>}
      <div>
        <button onClick={incrementHandler}>Increment</button>
        <button onClick={increaseHandler}>Increase by 5</button>
        <button onClick={decrementHandler}>Decrement</button>
      </div>
      <button onClick={toggleCounterHandler}>Toggle Counter</button>
    </main>
  );
};

export default Counter;

 

사용 시 주의 사항

  • 리덕스를 사용할 때 절대로 다음과 같이 변수 자체를 변형(Mutation)시키지 않는다.
  if (action.type === "increment") {
    state.counter++;
    
    return state;
  }

 

  • 그 대신에 다음과 같이 객체로 오버라이딩(Overriding)한다.
  if (action.type === "increment") {
    return {
      counter: state.counter + 1,
      showCounter: state.showCounter,
    };
  }

 

상태 업데이트 시 일부 상태만 설정하면 나머지 상태는 undefined로 처리되어 예상치 못한 결과를 초래할 수 있다. 따라서 상태 업데이트를 할 때는 모든 기존 상태를 포함하도록 새로운 상태 객체를 반환해야 한다.

 

 

리덕스 툴킷(Redux Toolkit, RTK)

개념

  • 리덕스를 더 쉽고 효율적으로 사용하기 위해 리덕스 툴킷(Redux Toolkit)을 사용할 수 있다.
$ npm install @reduxjs/toolkit   # yarn add @reduxjs/toolkit
$ npm install react-redux        # yarn add react-redux

 

리덕스 툴킷은 아래와 같은 라이브러리로 구성되어 있다.

@redux@ : 상태 관리 
@immer@ : 상태 불변성 관리

@redux-thunk@ : 비동기 액션 처리

@reselect@ : 셀렉터 최적화

 

  • 리덕스 툴킷은 리덕스의 일반적인 보일러플레이트(반복되는) 코드를 줄이고, 직관적이며 간결한 코드 작성을 가능하게 한다.
    • 기본 리덕스를 사용할 때는 많은 설정과 보일러플레이트 코드가 필요하다.
      • 예를 들어, 액션 생성자, 리듀서, 스토어 설정 등을 별도로 작성해야 하는데 리덕스 툴킷은 이러한 설정을 단순화하여 개발자의 작업을 줄여준다.
  • 리덕스 툴킷은 @configureStore@ 함수를 제공하여 스토어를 쉽게 설정할 수 있다.
    • 이 함수는 기본적인 미들웨어 설정과 디버깅 도구 설정을 자동으로 처리해준다.
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './reducers';

const store = configureStore({
  reducer: rootReducer,
});

 

  • 리덕스 툴킷의 @createSlice@ 함수는 액션 생성자리듀서를 한 번에 생성할 수 있게 해준다.
    • 리덕스의 핵심 개념을 더 직관적이고 간결하게 구현할 수 있다.
import { createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: 0,
  reducers: {
    increment: (state) => state + 1,
    decrement: (state) => state - 1,
  },
});

export const { increment, decrement } = counterSlice.actions;
export default counterSlice.reducer;

 

  • 리덕스 툴킷은 리덕스 개발자 도구(Redux DevTools)와 통합하여 상태 변화를 시각적으로 추적하고 디버깅하는 데 큰 도움을 준다.
  • 리덕스 툴킷은 타입 스크립트(TypeScript)와의 호환성을 강화하여, 타입 안전성을 유지하면서도 리덕스를 간편하게 사용할 수 있게 해준다.
  • 리덕스 툴킷은 @immer@를 사용하여 불변성 관리를 자동으로 처리해주며, 기본적으로 Redux Thunk 미들웨어를 포함한다.
    • 이러한 설정들은 개발자가 직접 최적화할 필요 없이 기본적으로 제공되어 개발 속도를 빠르게 해준다.
Redux Toolkit은 Redux를 더욱 편리하게 사용할 수 있도록 만들어진 도구로, Redux에서 자주 사용되는 기능들을 포함한 '배터리가 장착된' Redux라고 할 수 있다.

 

Immer와 상태 불변성(Immutability)

  • 리덕스를 사용할 때 절대로 변수 자체를 변형(Mutation)시키면 안된다.
  • 하지만, 리덕스 툴킷에서는 @createSlice@를 사용할 때 @Immer@라는 라이브러리가 자동으로 포함되어 있어, 불변성을 직접 관리할 필요 없이 더 직관적인 방식으로 상태를 변경할 수 있다.
  • 즉, 상태를 직접 변형하는 것처럼 보이는 코드를 작성해도 @Immer@가 내부적으로 상태를 불변성으로 유지해 준다.
const counterSlice = createSlice({
  name: 'counter',
  initialState: { counter: 0, showCounter: true },
  reducers: {
    increment(state) {
      state.counter++;
    }
  }
});

 

⇒ @state.counter++@는 상태를 직접 변형하는 것처럼 보이지만, @Immer@가 이 코드를 감싸서 실제로는 불변성을 유지한 상태로 새로운 상태를 반환하게 해준다.

 

리덕스 툴킷에서는 상태를 명시적으로 오버라이딩(Overriding)하는 방식도 사용할 수 있지만, Immer 라이브러리 때문에 상태를 변경하는 코드를 작성할 수도 있다.

 

 

createAsyncThunk() 함수

  • 리덕스 툴킷은 비동기 작업을 간편하게 처리할 수 있는 @createAsyncThunk@ 함수를 제공한다.
    • 비동기 작업의 상태 관리(로딩(@pending@), 성공(@fulfilled@), 실패(@rejected@) 등)를 쉽게 처리할 수 있다.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import axios from 'axios';

export const fetchUser = createAsyncThunk('users/fetchUser', async (userId) => {
  const response = await axios.get(`/api/users/${userId}`);
  return response.data;
});

const userSlice = createSlice({
  name: 'user',
  initialState: {
    user: null,
    loading: false,
    error: null,
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.loading = true;
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false;
        state.user = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message;
      });
  },
});

export default userSlice.reducer;

 

  • 관련 내용은 아래의 글을 참고한다.
 

[React.js] Thunk API (Redux Toolkit)

Thunk API (Redux Toolkit)들어가며리덕스 툴킷(Redux Toolkit)의 Thunk API에 대해 정리해본다. Thunk API개념비동기 작업을 간편하게 처리할 수 있는 도구로, 여러가지 기능을 제공한다.주로 API 호출과 같은

dev-astra.tistory.com

 

사용 방법

  • 실제 제작했었던 예제 코드를 바탕으로 사용 방법을 정리해본다.

 

슬라이스 생성하기

./src/features/cart/cartSlice.jsx
  • 슬라이스 파일을 생성해준다.
  • @./src@ 경로에 @features@ 폴더를 생성한 후, 전역 상태를 관리할 항목의 범주에 따라 폴더(@cart@)와 슬라이스 파일(@cartSlice.jsx@)을 생성해준다.
  • 슬라이스 파일(@cartSlice.jsx@)@createSlice@ 함수를 이용하여 슬라이스를 생성한다.
    • 슬라이스 이름(@name@) : @'cart'@
    • 상태 초기값(@initialState@) : @initialState@
    • 리듀서(@reducers@) : @{ action: (state, { payload }) => {}, ..., action: (state, { payload }) => {} }@
      • 리듀서 안에 액션(Action)들을 정의해준다.
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';

import { openModal } from '../modal/modalSlice';

const url = 'https://www.example-api.com/cart-project';

// 상태 초기값 설정하기
const initialState = {
  cartItems: [],
  amount: 0,
  total: 0,
  isLoading: true,
};

// API 통신 후 데이터 가져오기
export const getCartItems = createAsyncThunk(
  'cart/getCartItems',
  async (name, thunkAPI) => {
    try {
      // console.log(name);

      // thunkAPI를 이용하여 다양한 정보를 얻을 수 있다. (state 등)
      // console.log(thunkAPI);
      // console.log(thunkAPI.getState());
      // thunkAPI.dispatch(openModal());

      const resp = await axios(url);

      return resp.data;
    } catch (error) {
      return thunkAPI.rejectWithValue('something went wrong');
    }
  }
);

// 슬라이스 생성하기
const cartSlice = createSlice({
  // (1) 슬라이스 이름 설정
  name: 'cart',

  // (2) 상태 초기값 설정
  initialState,

  // (3) 리듀서 설정
  reducers: {
    // 액션1 : 카트 비우기
    clearCart: (state) => {
      state.cartItems = [];
    },

    // 액션2 : 아이템 삭제하기
    removeItem: (state, action) => {
      const itemId = action.payload;

      state.cartItems = state.cartItems.filter((item) => item.id !== itemId);
    },

    // 액션3 : 아이템 개수 증가시키기
    // payload에는 dispatch를 수행하는 외부 컴포넌트에서 보내는 값이 들어있다.
    increase: (state, { payload }) => {
      const cartItem = state.cartItems.find((item) => item.id === payload.id);

      cartItem.amount = cartItem.amount + 1;
    },

    // 액션4 : 아이템 개수 감소시키기
    decrease: (state, { payload }) => {
      const cartItem = state.cartItems.find((item) => item.id === payload.id);

      cartItem.amount = cartItem.amount - 1;
    },

    // 액션5 : 전체 아이템 개수 및 가격 계산하기
    calculateTotals: (state) => {
      let amount = 0;
      let total = 0;

      state.cartItems.forEach((item) => {
        amount += item.amount;
        total += item.amount * item.price;
      });

      state.amount = amount;
      state.total = total;
    },
  },

  // (4) 추가 리듀서 설정
  // RTK 2.0부터 객체 형태가 아닌, '빌더 콜백' 형태로 사용한다.
  extraReducers: (builder) => {
    builder
      // pending 상태일 경우 (pending)
      .addCase(getCartItems.pending, (state) => {
        state.isLoading = true;
      })

      // 모두 불러와졌을 경우 (fulfilled)
      .addCase(getCartItems.fulfilled, (state, action) => {
        // console.log(action);
        state.isLoading = false;
        state.cartItems = action.payload; // 불러와진 데이터로 cartItem 업데이트
      })

      // 거절되었을 경우 (rejected)
      .addCase(getCartItems.rejected, (state, action) => {
        console.log(action);
        state.isLoading = false;
      });
  },
});

// <1> 리듀서 내보내기
// - 기본 내보내기를 했기 때문에, 외부에서 불러올 때는 아무 이름으로 불러올 수 있다.
// 예) import cartReducer from './features/cart/cartSlice';
export default cartSlice.reducer;

// <2> 액션 내보내기
// 외부에서 사용할 수 있는 액션을 내보낸다.
export const { clearCart, removeItem, increase, decrease, calculateTotals } =
  cartSlice.actions;

 

./src/features/modal/modalSlice.jsx
import { createSlice } from '@reduxjs/toolkit';

const initialState = {
  isOpen: false,
};

const modalSlice = createSlice({
  name: 'modal',
  initialState,
  reducers: {
    // 액션1 : 모달 열기
    openModal: (state, action) => {
      state.isOpen = true;
    },

    // 액션2 : 모달 닫기
    closeModal: (state, action) => {
      state.isOpen = false;
    },
  },
});

// 리듀서 내보내기
export default modalSlice.reducer;

// 액션 내보내기
export const { openModal, closeModal } = modalSlice.actions;

 

스토어(Store) 생성하기

./src/store/index.js
  • @./src@ 경로에 @store@ 폴더를 만들고, 안에 @index.js@ 파일을 생성한다.
  • 그리고 안에서 위에서 생성했던 슬라이스 파일을 불러온 후, @configureStore()@를 이용하여 리듀서를 설정해준다.
    • 이제 외부 컴포넌트에서에서 이 저장소를 통해 슬라이스의 값(@cart@, @modal@)에 접근할 수 있게 된다.
import { configureStore } from '@reduxjs/toolkit';

// (참고) export default로 내보내기 했기 때문에, import 할 때는 아무런 이름을 지정하여 가져올 수 있다.
import cartReducer from '../features/cart/cartSlice';
import modalReducer from '../features/modal/modalSlice';

export const store = configureStore({
  reducer: {
    // 외부에서 접근할 때 다음과 같이 사용한다.
    // -> const { value } = useSelector((state) => state.cart);
    cart: cartReducer,
    modal: modalReducer,
  },
});

 

 

최상위 경로의 파일(@main.jsx)@에 스토어 적용하기

./src/main.jsx
  • 최상위 경로의 파일(@./src/main.jsx@)에 스토어(Store, @./src/store/index.js@)를 적용시켜준다.
    • @react-redux@ 패키지에서 @Provider@ 컴포넌트를 불러온다.
    • @./store/index.js@ 파일에서 @store@를 불러온다.
    • 최상위 컴포넌트인 @<App />@ 컴포넌트를 @<Provider@ 컴포넌트로 감싸 준다.
      • @store@ prop의 값을 @./store/index.js@ 파일에서 불러온 @store@로 설정해준다.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './index.css';

// 리덕스 사용하기
import { Provider } from 'react-redux';
import { store } from './store/index.js';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </StrictMode>
);

 

사용하기

  • 이제 전역 상태 관리를 위한 구성을 끝냈다.
  • 전역 상태 변수액션에 접근하는 방법을 정리해본다.

 

전역 상태 변수에 접근하기
  • @react-redux@ 패키지의 @useSelector@ 함수를 사용하여 전역 상태 변수에 접근할 수 있다.
import { CartIcon } from '../icons';
import { useSelector } from 'react-redux';

const Navbar = () => {
  // 전역 상태 변수에 접근하기
  const { amount } = useSelector((state) => state.cart);

  return (
    <nav>
      <div className="nav-center">
        <h3>Cart with Redux Toolkit</h3>
        <div className="nav-container">
          <CartIcon />
          <div className="amount-container">
            <p className="total-amount">{amount}</p>
          </div>
        </div>
      </div>
    </nav>
  );
};

export default Navbar;

 

액션에 디스패치 하기
  • @react-redux@ 패키지의 @useDispatch@ 함수를 이용하여 액션 디스패치할 수 있다.
  • 이때, 디스패치하기 위해 필요한 함수들을 슬라이스 파일에서 불러와준다.
    • @import { removeItem, increase, decrease } from '../features/cart/cartSlice';@
  • 이렇게 디스패치를 함으로써 전역적으로 상태를 변화시킬 수 있다.
import { useDispatch } from 'react-redux';

import { ChevronDown, ChevronUp } from '../icons';
import { removeItem, increase, decrease } from '../features/cart/cartSlice';

const CartItem = ({ id, img, title, price, amount }) => {
  const dispatch = useDispatch();

  return (
    <article className="cart-item">
      <img src={img} alt={title} />
      <div>
        <h4>{title}</h4>
        <h4 className="item-price">${price}</h4>

        {/* remove button */}
        <button className="remove-btn" onClick={() => dispatch(removeItem(id))}>
          remove
        </button>
      </div>
      <div>
        {/* increase amount */}
        <button
          className="amount-btn"
          onClick={() => dispatch(increase({ id }))}
        >
          <ChevronUp />
        </button>

        {/* amount */}
        <p className="amount">{amount}</p>

        {/* decrease amount */}
        <button
          className="amount-btn"
          onClick={() => {
            if (amount === 1) {
              dispatch(removeItem(id));
              return;
            }
            dispatch(decrease({ id }));
          }}
        >
          <ChevronDown />
        </button>
      </div>
    </article>
  );
};

export default CartItem;

 

프로젝트 구조 정리
/src
├─ /features ─ /cart/cartSlice.js, /modal/modalSlice   → 슬라이스 파일 보관
├─ /store ─ index.js  → 스토어 파일 보관
├─ /components  → 전역 상태 변수 접근 및 액션 디스페치 
├─ App.jsx
└─ index.jsx  → 스토어 적용

 

사용 시 주의 사항

  • RTK 2.0부터는 @createReducer@와 @createSlice.extraReducers@에서 객체(Object) 형태의 사용을 더 이상 지원하지 않는다.
// RTK 2.0 이전의 방식
const cartSlice = createSlice({
  // ...
  extraReducers: {
    [getCartItems.pending]: (state) => {
      state.isLoading = true;
    },
    [getCartItems.fulfilled]: (state, action) => {
      console.log(action);
      state.isLoading = false;
      state.cartItems = action.payload;
    },
    [getCartItems.rejected]: (state) => {
      state.isLoading = false;
    },
  },
}

 

  • 빌더(Builder) 콜백 형태간결하며, 타입스크립트(TypeScript)와의 통합에서도 더 효과적이기 때문이다.
// RTK 2.0 이후의 방식 
// -> 빌더 콜백 형태로 사용한다.
const cartSlice = createSlice({
  // ...
  extraReducers: (builder) => {
    builder
    .addCase(getCartItems.pending, (state) => {
      state.isLoading = true;
    })
    .addCase(getCartItems.fulfilled, (state, action) => {
      // console.log(action);
      state.isLoading = false;
      state.cartItems = action.payload;
    })
    .addCase(getCartItems.rejected, (state, action) => {
      console.log(action);
      state.isLoading = false;
    });
  },
}

 

함께 사용하면 좋은 것들

Redux DevTools

  • 브라우저 확장 프로그램이다.
  • 애플리케이션의 상태(@state@)를 쉽게 디버깅하고 추적할 수 있게 도와준다.
  • 애플리케이션의 상태 변화, 액션(@action@)의 흐름, 상태 변화 전후의 차이를 실시간으로 확인할 수 있다.
 

Redux DevTools - Chrome 웹 스토어

Redux DevTools for debugging application's state changes.

chromewebstore.google.com

 

참고 사이트

 

Redux - A JS library for predictable and maintainable global state management | Redux

A JS library for predictable and maintainable global state management

redux.js.org

 

Redux Toolkit | Redux Toolkit

The official, opinionated, batteries-included toolset for efficient Redux development

redux-toolkit.js.org

 

728x90
728x90