장바구니 구현! (Redux Toolkit + Redux-persist)

코린·2024년 1월 4일
0

리액트

목록 보기
18/22

사실 장바구니 기능을 구현하게 되면서 Redux Toolkit을 알게되었습니다!

🧐 장바구니에 왜 상태관리라이브러리..?

사실 장바구니에 대한 정보는 서버에서 관리할 필요가 있을 수도 없을 수도 있습니다!

장바구니는 단순히 물건을 담아놓았을 뿐, 실제 구매를 하지는 않은 값 입니다!
그래서 딱히 서버에 내용을 저장할 이유는 없겠죠..?

하지만 저장해야 하는 경우도 있습니다!

로그인 한 회원이 웹이든 앱이든 어떤 곳에서 접속을 해도 해당 사용자가 가지고 있는 장바구니에 대한 정보를 저장하고 싶다면!
이때는 상태 관리 라이브러리가 아닌 따로 서버에 저장을 해서 관리해주어야 합니다!

저는 따로 서버가 없고 단순히 기능만 구현할 거라 상태관리라이브러리를 사용하도록 하겠습니다!ㅎㅎ

💜 Redux Toolkit

왜 많고 많은 라이브러리 중에 이건가요?? 하신다면,,,

일단 이걸 보십쇼!

간단하게 정리를 하자면

  • Redux

    • "액션" 이벤트를 사용해 애플리케이션 상태를 관리하고 업데이트
    • Flux 패턴

      이미지 출처
  • Recoil

    • Atom 이라는 하나의 전역 상태를 선언
      • 이는 모든 컴포넌트에서 접근 가능함

    • 이미지 출처
      - Atom의 상태를 구독하여 업데이트 되는 Selector
  • Zustand

    • 단순화된 Flux 원리를 사용
    • Hooks 기반

인데 사실 필요에 따라 사용하면 됩니다..!

저는 리덕스가 나온지 오래되었기 때문에 관련 내용도 많고 편리할 것으로 생각되어 사용하려 했습니다..!

(뒤에서 말할거긴 하지만 redux-persist 때문에 사용한 것도 있습니다!)



리덕스는 단점도 있습니다.

  • 보일러 플레이트 코드
  • 상태를 읽기 전용으로 취급, 상태를 실수로 변경하지 않기 위해서 유의해야 함!
    - 불변성을 위해서 Immutable.js를 사용

이러한 점들을 보완해서 나온것이 바로

Redux-toolkit 입니다!

여기서 제가 좀 더 알아보고 싶었던 부분은 바로 불변성 입니다.

🚨 리덕스와 불변성

🗒️ 리덕스의 3가지 규칙

1. 하나의 애플리케이션 안에는 하나의 스토어가 있습니다.

2. 상태는 읽기전용 입니다.

3. 변화를 일으키는 함수, 리듀서는 순수한 함수여야 합니다.

이 규칙 중 2번째가 불변성 유지와 연관이 있습니다.

리덕스에서 불변성 을 유지해야 하는 이유는 내부적으로 데이터가 변경 되는 것을 감지하기 위해서 shallow equality 검사를 하기 때문입니다.

또한! 개발자 도구를 이용하면 상태를 뒤로 돌릴 수도 있고 앞으로도 돌릴 수 있습니다!

Redux Toolkit 과 immer

Redux Toolkit의 createReducer API는 내부적으로 자동으로 Immer를 사용합니다.

이를 통해서 불변성을 유지해 createReducer에 전달되는 모든 리듀서 함수 내부에서 상태를 "변경"하는 것이 이미 안전하게 됩니다.

그래서 Immer 패턴 사용시에는 상태 값을 변경하거나 새로운 상태 값을 반환하는 것이 좋습니다!

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    // ❌ ERROR: mutates state, but also returns new array size!
    brokenReducer: (state, action) => state.push(action.payload),
    // ✅ SAFE: the `void` keyword prevents a return value
    fixedReducer1: (state, action) => void state.push(action.payload),
    // ✅ SAFE: curly braces make this a function body and no return
    fixedReducer2: (state, action) => {
      state.push(action.payload)
    },
  },
})
출처: https://itchallenger.tistory.com/706 [Development & Investing:티스토리]

1번째는 변경된 상태를 반환하고 있기 때문에 돌연변이 상태입니다!

2번째는 'void' 키워드를 통해서 반환값을 막아주고 있기 때문에 안전합니다!

3번째는 함수안에 넣었기 때문에 따로 반환값이 없어 안전합니다!

이러한 패턴을 지켜서 Redux-toolkit을 사용하도록 합시다!

✏️ 장바구니를 만들어보자!

장바구니 기능

  • 장바구니에 물건 담기
  • 장바구니 상품 수량 조절하기
  • 장바구니 상품 총액 보여주기

간단하게 필요한 기능만 정리했습니다!

  1. Redux Toolkit 설치
npm install @reduxjs/toolkit
  1. store.js 작성

(저는 store 폴더를 만들고 그 안에 작성했습니다!)

import cart from "./cart/cartSlice.js";
import { configureStore } from "@reduxjs/toolkit";

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

export default store;

store는 애플리케이션의 전역 상태를 저장하고 관리하는 역할을 합니다.

configureStore의 역할은 Redux 스토어를 쉽게 생성할 수 있도록 도와줍니다.

  • Reducer를 합치는 작업
    • reducer 필드에 각 slice의 reducer를 객체 형태로 전달하면, configureStore가 이들을 합쳐서 최종 reducer를 생성
  • Redux DevTools 확장 프로그램과의 통합
  • 미들웨어 설정
    • Redux Thunk를 기본 미들웨어로 포함
  1. 최상위 컴포넌트에 Provider 추가
import React from "react";
import ReactDOM from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { RouterInfo } from "./util/router.jsx";
import { Provider } from "react-redux";
import store from "./store/store.js";

const RouterObject = createBrowserRouter(RouterInfo);

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <Provider store={store}>
      <RouterProvider router={RouterObject} />
    </Provider>
  </React.StrictMode>
);

Provider를 이용해 전역 상태를 사용할 범위를 지정해줍니다.
이때 앞에서 작성했던 store 파일을 prop으로 전달받습니다!

  1. cartSlice.js 작성
import { createSlice, createSelector } from "@reduxjs/toolkit";

let cart = createSlice({
  name: "cart",
  initialState: [],
  reducers: {
    increaseCount(state, action) {
      const item = state.find((obj) => obj.id === action.payload);
      if (item) {
        item.count += 1;
      }
    },
    decreaseCount(state, action) {
      const itemId = action.payload;
      const updatedState = state.map((item) => {
        if (item.id === itemId) {
          return {
            ...item,
            count: Math.max(0, item.count - 1),
          };
        }
        return item;
      });
      return updatedState.filter((item) => item.count > 0);
    },
    insertItem(state, action) {
      const newItem = action.payload;
      const existingIndex = state.findIndex((obj) => obj.id === newItem.id);

      if (existingIndex === -1) {
        return [...state, newItem];
      } else {
        return state.map((item, index) =>
          index === existingIndex
            ? {
                ...item,
                count: item.count + newItem.count,
              }
            : item
        );
      }
    },
    clearCart() {
      return [];
    },
  },
});

export let { increaseCount, decreaseCount, insertItem, clearCart } =
  cart.actions;

export const selectCartItems = (state) => state.cart;

export const selectCartTotal = createSelector([selectCartItems], (cartItems) =>
  cartItems.reduce(
    (total, item) =>
      total +
      (item.discountPercent === 0
        ? item.price * item.count
        : (item.price * (100 - item.discountPercent)) / 100) *
        item.count,
    0
  )
);

export default cart;

상태, 리듀서, 액션 생성 함수를 포함하는 slice 파일을 만듭니다!

  • initialState로 초기 상태를 정의합니다.
    • 여러개의 객체를 배열로 관리할 것이므로 빈 배열로 초기화
  • reducer 구현
    • 리듀서는 상태와 액션을 인자로 받아 새로운 상태를 반환하는 순수 함수

구현한 액션함수

  • increaseCount
    • 상품 갯수 증가
  • decreaseCount
    • 상품 갯수 감소
  • insertItem
    • 상품 추가
  • clearCart
    • 상품 초기화

🚨 불변성은...?

Redux Toolkit은 Immer 라이브러리를 내부적으로 사용하고 있으므로, 이 라이브러리 덕분에 상태를 직접 변경>하는 것처럼 코드를 작성해도 불변성이 유지됩니다!

그리고 저는 선택자를 만들어 주었습니다.

export const selectCartTotal = createSelector([selectCartItems], (cartItems) =>
  cartItems.reduce(
    (total, item) =>
      total +
      (item.discountPercent === 0
        ? item.price * item.count
        : (item.price * (100 - item.discountPercent)) / 100) *
        item.count,
    0
  )
);

선택자는 상태를 읽어오는 함수의 일종입니다.
따라서 전역적으로 관리되는 상태를 읽어오는 역할을 합니다!
저는 금액의 총액을 편하게 받아오기 위해서 위와 같은 선택자를 생성해주었습니다!

  1. useSelector와 useDispatch로 컴포넌트에서 사용해보자!

useDispatch

store의 디스패치 함수를 반환합니다.
useDispatch를 통해 액션을 dispatch 하면, 해당 액션을 처리하는 리듀서가 실행되어 상태가 업데이트 됩니다.

import { useDispatch } from "react-redux";
import { insertItem } from "../store/cart/cartSlice";

const dispatch = useDispatch();
dispatch(insertItem(obj));

위 처럼 액션 함수를 사용할 수 있게 됩니다.
저같은 경우에는 장바구니에 상품을 추가할 때 사용해 주었습니다!

useSelector

store의 상태를 조회하는데 사용합니다.
useSelector 에는 상태를 조회하는 선택자 함수를 인자로 전달하면, 해당 선택자를 통해 조회된 상태를 반환받을 수 있습니다.

import { useSelector } from "react-redux";
import { selectCartTotal } from "../store/cart/cartSlice";

const total = useSelector(selectCartTotal);

이 경우가 선택자를 인자로 줘서 사용하는 경우입니다!

import { useSelector } from "react-redux";

const list = useSelector((state) => state.cart);

이 경우는 단순히 상태 값을 가져와서 사용하는 경우입니다!
따라서 useSelector 인자로 함수를 주어 장바구니에 있는 모든 상태 값을 가져오고 있습니다.

📺 실행화면

잘 작동하는 것을 확인하실 수 있습니다!

근데 사실.... 저희가 짠 장바구니는 새로고침하면 사라지게 됩니다!!!! 뜨헉...??

🦄 Redux-persist

상태를 브라우저에 지속적으로 저장할 수 있도록 해주는 라이브러리입니다!
따라서 새로고침을 해도 저희의 상태가 유지되게 됩니다!

냅다 사용!

  1. 설치
npm install redux-persist
  1. store.js 수정

persistReducer와 persistStore를 사용해 리듀서와 스토어를 감싸줍니다.

import { configureStore } from "@reduxjs/toolkit";
import cart from "./cart/cartSlice";
import storage from "redux-persist/lib/storage";
import { combineReducers } from "redux";
import { persistStore, persistReducer } from "redux-persist";

const persistConfig = {
  key: "root",
  storage: storage,
};

const reducer = combineReducers({
  cart: cart.reducer,
});

const persistedReducer = persistReducer(persistConfig, reducer);

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

export const persistor = persistStore(store);
  • persistReducer

    • 리듀서를 감싸서 리듀서의 상태를 지속적으로 유지하도록 만드는 역할
    • 상태를 저장하고 복웒는 추가적인 액션을 처리
  • persistStore

    • 스토어의 상태를 지속적으로 유지하도록 만드는 역할
    • 함수 호출 시, 스토어의 현재 상태를 저장
    • 저장된 상태를 복원하는데 필요한 persistor 객체를 반환

🧐 스토리지 선택?

세션과 로컬 차이

  • LocalStorage

    • 생명주기: 브라우저를 닫거나 컴퓨터를 재부팅 해도 유지
      - 범위: 동일한 프로토콜, 도메인, 포트를 가진 모든 탭 또는 윈도우에서 공유
  • SessionStorage

    • 생명주기: 브라우저를 열어 탭을 로드하는 시점부터 시작, 브라우저를 닫거나 탭을 닫는 시점에 종료
    • 범위: 각 탭 또는 윈도우마다 독립적 , 한 탭에서 세션 스토리지를 변경하더라도 이 변경 사항은 다른 탭에 영향을 미치지 않음

장바구니는 계속 값이 유지되고, 값들이 독립적으로 동작하면 안되기 때문에 로컬스토리지를 쓰는게 더 적합하다고 판단됩니다!

로컬 스토리지

import storage from "redux-persist/lib/storage";

const persistConfig = {
  key: "root",
  storage: storage,
};

윗 부분을 통해서 로컬 스토리지에 내용을 저장하게 됩니다!

세션 스토리지

import storageSession from 'redux-persist/lib/storage/session';

const persistConfig = {
  key: 'root',
  storage: storageSession, // 변경된 부분
};

위와 같이 import 해주고 persistConfig의 storage를 변경해주면 됩니다!

  1. 최상위 컴포넌트에서 PersisGate로 앱을 감싸줍니다.
import React from "react";
import ReactDOM from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { RouterInfo } from "./util/router.jsx";
import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react";
import { store, persistor } from "./store/store.js"; // Redux 스토어와 persistor 가져오기

const RouterObject = createBrowserRouter(RouterInfo);

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <Provider store={store}>
      <PersistGate loading={null} persistor={persistor}>
        <RouterProvider router={RouterObject} />
      </PersistGate>
    </Provider>
  </React.StrictMode>
);
  • PersistGate
    • 해당 컴포넌트는 앱의 렌더링을 지연, 저장된 상태가 복원되는 동안 앱이 렌더링 되지 않도록 막음
    • persistStore로 만든 persistor를 props로 전달받아, 저장된 상태가 복원될 때까지 앱의 렌더링을 지연

📺 실행화면!

장바구니에 값도 잘 들어가고
무엇보다도! 새로고침해도 값이 사라지지 않습니다!

이렇게 확인해보면 로컬 스토리지에 값이 들어간 것이 보입니다!

.
.
.
.
.

긴 글... 읽어주셔서 감사합니다!

참고 블로그

리덕스를 써야 할 때 & 리덕스의 장단점 (feat. 공식문서)
Recoil을 이용한 손쉬운 상태관리
상태 관리 라이브러리
리덕스의 장점, 단점
리덕스의 3가지 규칙
Redux Toolkit : Immer와 함께 사용하기
[React] 장바구니에 아이템 추가해보기

profile
안녕하세요 코린입니다!

0개의 댓글