기존에 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;
'UMC 8th Web 워크북' 카테고리의 다른 글
🐻zustand로 장바구니 만들기🛒 (0) | 2025.05.30 |
---|---|
🐻 Zustand 배우기 (0) | 2025.05.30 |
🍎 useReducer를 왜 쓸까 (0) | 2025.05.29 |
⚽ useDebounce 활용해서 검색하기 (0) | 2025.05.17 |
🌐낙관적 업데이트 (Optimistic Update) (0) | 2025.05.15 |