UMC 8th Web 워크북

🔧Redux-Toolkit을 활용해서 쇼핑 카트 만들기

minnote29 2025. 5. 29. 15:20
기존에 context API를 활용하면서 코드가 복잡해지고 비교적 간단한 데이터를 공유해야 하고, 빈번하게 렌더링이 발생한다거나 자체적으로 지원해주는 미들웨어 기능 같은 것이 없다. 
리듀서를 활용했을 때 어떤 것이 문제이고 이 문제를 해결하기 위해서 리덕스 툴킷이 왜 도입되었는지 집중해서 활용해보는 것이 좋다. 

 

기존에 만든 영화 서비스와는 다르게 장바구니 서비스를 만들 것이다. 서버 api는 존재 x.

리덕스 툴킷을 활용하면 비동기 처리 미들 웨어 등을 활용해서 처리할 수 있다. 

 

리덕스 툴깃으로 장바구니 서비스 만들기

$ pnpm create vite
$ pnpm i 

장바구니 서비스 관련 프로젝트를 생성한다. (+ 의존성 설치)

 

tailwindcss도 설치

$ pnpm install tailwindcss @tailwindcss/vite 
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc'
import tailwindcss from '@tailwindcss/vite';

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
})

vite.config.ts

@import 'tailwindcss';

index.css

App.css 내용 지우기

 

-> pnpm run dev로 실행.

 

목데이터를 이용해서 cartItems를 처리한다. -> 이것은 여기서 생략.

import './App.css';
import CartList from './components/CartList';
import NavBar from './components/NavBar';

function App() {
  return (
    <div>
      <>
        <NavBar />
        <CartList />
      </>
    </div>
  );
}

export default App;

 

 

 

리덕스 툴킷

$ pnpm i @reduxjs/toolkit react-redux

리덕스 툴킷을 사용하기 위해서 store를 만들어줘야 한다. -> 중앙 저장소 - store.ts

그 다음 리듀서 설정도 해주고, store를 외부에서 활용할 수 있게 전달을 해줘야 한다.

리덕스도 context API처럼 우산의 개념이라서 Provider를 사용할 수 있다.

import { Provider } from 'react-redux';
import './App.css';
import CartList from './components/CartList';
import NavBar from './components/Navbar';
import store from './store/store';

function App() {
  return (
    <Provider store={store}> 
      <NavBar />
      <CartList />
    </Provider>
  );
}

export default App;

 

리덕스 툴킷의 가장 큰 강점은 프로젝트의 규모가 크면 클수록 여러가지의 전역 상태가 필요한 앱이라고 생각하면 역할이 피쳐별로 명시가 되는 것이 좋다. 유지보수성, 확장 가능성을 위해서 복수로 이름을 설정하는 것이 좋다. -> slices

초기 상태에 대한 인터페이스 만들어준다. (cartSlice.ts)

export interface CartState {
    cartItems: CartItems;
}

 

장바구니 UI에서 생각해보면 전체 LP에 관한 목록도 상태로 관리를 해야 하지만 이 가격이 얼만지도 상태로 관리를 해야 할 것 같고 전체 수량도 상태로 관리하면 편할 것이다. 

prop을 전달해서 처리해줘도 되지만 현재 navbar랑 cartlist랑 같이 연결이 돼 있어야 한다. 같은 경로에 있을 때 props를 전달해주기가 되게 애매해진다. 점점 깊이가 깊어질수록 힘들어지기 때문에 contextAPI를 사용했었고, 이번에는 리덕스 툴킷을 활용하는 것이다. 

store에서 todo 증가, 감소, 아이템 제거를 해야 하는데 아이템 제거는 예외 케이스이다. 이 부분에 대해서 잘 생각해봐야 한다. 만약에 수량 하나가 들어갔는데 0개이면 굳이 장바구니 안에 띄울 필요가 없다. -> 아이템 제거 필요.

import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
import cartItems from "../constants/cartItems";
import type { CartItems } from "../types/cart";

export interface CartState {
    cartItems: CartItems;
    amount: number;
    total: number;
};

const initialState: CartState = {
    cartItems: cartItems,
    amount: 0,
    total: 0,
}
// cartSlice 생성
// createSlice -> reduxToolkit에서 제공
const cartSlice = createSlice({
    name: 'cart',
    initialState,
    reducers: {
        // TODO: 증가
        increase: (state, action: PayloadAction<{ id: string }>) => {
            const itemId = action.payload.id;
            // 이 아이디를 통해서, 전체 음반 중에 내가 클릭한 음반을 찾기
            const item = state.cartItems.find((cartItem) => cartItem.id === itemId);

            if (item) {
                item.amount += 1; 
            }
        },
        // TODO: 감소
        decrease: (state, action: PayloadAction<{ id: string }>) => {
            const itemId = action.payload.id;
            // 이 아이디를 통해서, 전체 음반 중에 내가 클릭한 음반을 찾기
            const item = state.cartItems.find((cartItem) => cartItem.id === itemId);

            if (item) {
                item.amount -= 1; 
            }
        },
        // TODO: removeItem 아이템 제거
        removeItem: (state, action: PayloadAction<{ id: string }>) => {
            const itemId = action.payload.id;
            // 내가 고른 것 빼고 나머지를 필터링
            state.cartItems = state.cartItems.filter(
                (cartItem) => cartItem.id !== itemId
            );
        },
        // TODO: clearCart 장바구니 비우기
        clearCart: (state) => {
            state.cartItems = [];
        },
        // TODO: 총액 계산
        calculateTotals: (state) => {
            // 총액 계산
            let total = 0;
            let amount = 0;

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

            state.amount = amount; // 총 수량 업데이트
            state.total = total; // 총액 업데이트
        }
    } // 액션에 대한 정의
})

// duck pattern reducer는 export default로 내보내야 함.
const cartReducer = cartSlice.reducer;

export default cartReducer;

이렇게 했으면 외부에서도 써야 하므로 내보내줘야 한다. export

// 액션 내보내기
export const {increase, decrease, removeItem, clearCart, calculateTotals} = 
    cartSlice.actions;

카트 리스트를 보면, 이 카트 아이템들을 가져와야 한다. 현재는 목 데이터를 그냥 갖고 오는 것이기 때문에 이것을 활용하는 것이 아니라 전역 상태로 관리하는 것을 가져와야 액션을 취했을 때 반영이 될 것이다. useSelector를 통해서 갖고 오고, state에 접근하면 들어가 있을 것이다. 아래와 같이 작성하면 오류가 나는데 이에 대해서는 공식문서를 참고해서 변경을 해줘야 한다. store 파일에 두 가지 내용 추가.

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

X

 

// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch;
import { useSelector } from "react-redux";
import CartItem from "./CartItem";
import type { RootState } from "../store/store";

const CartList = () => {
  const { cartItems, amount, total } = useSelector(
    (state: RootState) => state.cart
  );

  return (
    <div className='flex flex-col items-center justify-center'>
        <ul>
            {cartItems.map((item) => (
                <CartItem key={item.id} lp={item} />
            ))}
        </ul>
    </div>
  )
}

export default CartList;

 

 

특정 코드에 대해서 반복해서 쓰는 것을 hooks 폴더에 넣어서 관리해야 한다. -> 훅 만들기

import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux";
import type { AppDispatch, RootState } from "../store/store";

export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

이렇게 해주고, CartList에서 useAppSelector를 써주면 된다. state도 굳이 따로 처리해줄 필요가 없음.

const { cartItems, amount, total } = useAppSelector(
    (state: RootState) => state.cart
  );

 

 

훅으로 잘 빼긴 했는데 지금 문제점은 useSelector라는 이름을 쓰기 위해서 바꿔주면 되는데 아마 이름이 겹쳐서 문제가 발생할 것이다. 여기에 as useDefaultSelector 이런 식으로 넣어주고 수정을 해주면 되고, 아래처럼 useSelector 이름으로 쓸 수가 있다. 

import { useDispatch as useDefaultDispatch, useSelector as useDefaultSelector, type TypedUseSelectorHook } from "react-redux";
import type { AppDispatch, RootState } from "../store/store";

export const useAppDispatch: () => AppDispatch = useDefaultDispatch;
export const useSelector: TypedUseSelectorHook<RootState> = useDefaultSelector;

useCustomRedux 파일을 import 해서 잘 처리해주면 된다. 헷갈리면 굳이 useSelector와 동일한 이름으로 할 필요는 없다. 

 

 

 

이제 CartItem에서 액션을 쓰기 위해서 dispatch를 가져와야 한다. 

앞선 과정에서 아이템의 수량이 1보다 작을 경우에는 amount를 하는 것이 아니라 아이템을 제거할 수도 있었다. 근데 이것을 굳이 만들어준 이유는 이 케이스 안에서 제거하는 역할만 부여하기 위해서 라고 생각하면 된다.

 

아래 코드처럼 입력하면 전체 선택된 수량이 떠야 되는데 0이 뜰 것이다. 

import { FaShoppingCart } from "react-icons/fa"
import { useSelector } from "../hooks/useCustomRedux"

const NavBar = () => {
  const { total } = useSelector((state) => state.cart)

  return (
    <div className="flex justify-between items-center p-4 bg-gray-800 text-white">
        NavBar
        <h1 className="text-2xl font-semibold">Othani Ahn</h1>
        <div className="flext items-center space-x-2"> 
            <FaShoppingCart className='text-2xl'/>
            <span className="text-xl font-medium">{total}</span>
        </div>
    </div>
  )
}

export default NavBar

이것은 반영이 안 된 것인데, 그 이유는 일단 초기값을 0으로 설정해서 0이 나옴. 근데 증가를 하려면 어떻게 해야 하나.. -> increase, decrease 되면 calculateTotals가 일어나야 한다. -> useEffect의 dispatch를 활용.

 

정정해서 다시.

amount 같은 경우는 어떻게 계산이 돼 가고 있냐면 state.item이 매번 돌면서 amount를 증가시켜서 주고, item.price 즉, amount의 total을 계속 순회하면서 calculateTotals를 계산하고 있는 구조이다. -> PriceBox에서 연결을 해줘야 함.

import { useSelector } from "../hooks/useCustomRedux";

const PriceBox = () => {
  const { total } = useSelector((state) => state.cart);

  return (
    <div className='p-12 flex justify-end'>총 가격: {total}원</div>
  )
};

export default PriceBox;

이렇게 하면 총 가격을 구할 수 있는데, 

화면을 보면 아마 총 가격이랑 수량 둘 다 증가가 안 되는 현상을 볼 수 있다. 

왜 증가가 안 되냐면 전체적으로 리렌더링이 되기는 하지만 지금 문제점은 store에서 calculateTotals를 통해서 총액을 계속 계산 하고 있다. 이 말은 지금 총액도 계산을 하지만 amount 부분도 버튼을 눌렀을 때 업데이트가 되야 한다는 것이다. -> amount & total 상태가 계속 업데이트 되야 함.

=> 계속 업데이트 즉, 최신화가 안 되고 있는 상황.

dispatch를 이용한다. 아마 정상적으로 작동할 것이다. 왜냐면 이 dispatch가 실행될 때마다 calculateTotals가 계속 동작을 하기 때문에. 

근데 의존성 배열 즉, 디펜던시를 설정 안 하는 것을 좋지 않다.

의존성 배열에는 dispatch를 적어줌.

const dispatch = useDispatch();

  useEffect(() => {
    dispatch(calculateTotals());
  }, [dispatch]);

 

카트 리스트도 바뀔 때마다 실행을 하는 게 좋아서 그것에 대해서도 처리를 해준다.

const { amount, cartItems } = useSelector((state) => state.cart);
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(calculateTotals());
  }, [dispatch, cartItems]);

cartItems도 업데이트를 해줌으로써 amount와 total이 정상적으로 증가하는 것을 볼 수 있다. 

import { FaShoppingCart } from "react-icons/fa"
import { useSelector } from "../hooks/useCustomRedux"
import { useEffect } from "react"
import { calculateTotals } from "../slices/cartSlice"
import { useDispatch } from "../hooks/useCustomRedux"

const NavBar = () => {
  const { amount, cartItems } = useSelector((state) => state.cart);
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(calculateTotals());
  }, [dispatch, cartItems]);

  return (
    <div className="flex justify-between items-center p-4 bg-gray-800 text-white">
      <h1
        onClick={() => {
          window.location.href = '/';
        }} 
        className="text-2xl font-semibold cursor-pointer"
      >
        GU JOOCHA
      </h1>
      <div className="flext items-center space-x-2"> 
        <FaShoppingCart className='text-2xl'/>
        <span className="text-xl font-medium">{amount}</span>
      </div>
    </div>
  )
}

export default NavBar;

 

 

장바구니 초기화 - dispatch 사용.

import { useDispatch, useSelector } from "../hooks/useCustomRedux";
import { clearCart } from "../slices/cartSlice";

const PriceBox = () => {
  const { total } = useSelector((state) => state.cart);
  const dispatch = useDispatch();

  const handleInitializeCart = () => {
    dispatch(clearCart());
  };

  return (
    <div className='p-12 flex justify-between'>
      <button 
        onClick={handleInitializeCart}
        className="border p-4 rounded-md cursor-pointer"
      >
        장바구니 초기화
      </button> 
      <div>총 가격: {total}원</div>
    </div>
  )
};

export default PriceBox;