Next.js 13과 redux/toolkit, styled-components 등으로 북마크 기능이 들어있는 쇼핑몰 앱을 구현한 프로젝트이며, 설명에 필요한 코드만 최소한으로 서술되었습니다. :
(1) gitHub - README 및 전체 코드 보러 가기
(2) 프로젝트 요구 명세서 보기 (해당 프로젝트 첫 포스팅)
저번 포스팅에서는 Redux Toolkit의 추가 기능인 Redux Toolkit Query(이하 RTK Query)를 이용하여 데이터를 불러오는 방법을 다뤄보았다. 이어서, RTK을 사용하여 전역으로 데이터를 관리한 방법에 대해 정리해보겠다.
결과화면
1. 북마크 데이터 전역으로 관리하기
- 현재 아이템 2개가 북마크에 추가되어 있다.
- 북마크된 아이템 데이터가 Local Storage에도 담겨있다. (즉, 브라우저를 껐다 켜도 데이터는 유지된다.)
- 북마크된 아이템의 별 아이콘이 어느 페이지에서든 활성화되어있다.
2. 토스트 데이터 전역으로 관리하기
- 아이템이 북마크에서 추가되고 삭제될 때마다 토스트 창이 뜨고 일정 시간 뒤에 사라진다.
- 어느 페이지를 가든 자연스럽게 일정 시간 후에 사라지는 모습을 볼 수 있다.
1. 북마크 데이터 전역으로 관리하기
(provider와 hook 등 자세한 코드는 1편을 참조)
reducer 생성
redux/toolkit의 createSlice를 사용하여 Redux의 리듀서를 생성한다.
- 아래 코드는 "bookMarkReducer"라는 이름으로 생성된 리듀서이다.
bookmarkReducer.js
import { createSlice } from "@reduxjs/toolkit";
// 초기 데이터는 bookmarkedProducts라는 이름으로 localStorage에 데이터가 저장되어 있다면
// initialState에 해당 데이터를 파싱한 값을 담아준다.
const initialState = JSON.parse(localStorage.getItem("bookmarkedProducts"));
// createSlice 함수를 사용하여 리듀서와 관련된 액션 및 리듀서 함수를 생성한다.
export const bookMarkedProducts = createSlice({
name: "bookMarkReducer", // 리듀서의 이름
initialState, // 초기 상태로 설정한 값
// 객체 내부에 액션 및 리듀서 함수를 정의한다
reducers: {
// 새로운 즐겨찾기 상품을 추가하는 액션 (현재 상태를 복사 후 새로운 상태를 추가 -> localStorage에 업데이트)
addBookMarkedProducts: (state, action) => {
const updatedState = [
...state,
{ value: action.payload, isBookmarked: true },
];
localStorage.setItem("bookmarkedProducts", JSON.stringify(updatedState));
return updatedState;
},
// 즐겨찾기 상품을 삭제하는 액션(삭제할 상품을 제외한 상태를 생성 -> localStorage에 업데이트)
deleteBookMarkedProduct: (state, action) => {
const updatedState = state.filter(
(item) => item.value.id !== action.payload.value.id
);
localStorage.setItem("bookmarkedProducts", JSON.stringify(updatedState));
return updatedState;
},
},
});
export const { addBookMarkedProducts, deleteBookMarkedProduct } =
bookMarkedProducts.actions; // createSlice로 생성된 액션들을 외부에서 호출할 수 있도록 함
export default bookMarkedProducts.reducer; // 리듀서 또한 export 시켜 store에 리듀서를 등록할 수 있도록 함
store에 reducer 추가해주기
토스트 리듀서도 미리 추가된 모습이다.
store
import { configureStore } from "@reduxjs/toolkit";
import bookMarkedProducts from "./bookmarkReducer";
import toastReducer from "./ToastReducer";
export const store = configureStore({
reducer: {
bookMarkedProducts, // 북마크 리듀서
toastReducer, // 토스트 리듀서
.
.
},
devTools: process.env.NODE_ENV !== "production",
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat([productApi.middleware]),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
+ 북마크 활성화 상태 추가해주기
Local Storage에 저장된 아이템 데이터 형태는 다음과 같다.
- 최초로 id가 11인 "윌슨 테니스공" Product 카테고리의 아이템이 북마크에 추가된 경우
- [ { "value" : {구찌 브랜드 데이터},
"isBookmarked" : true} ] - 여기서 isBookmarked는 별 아이콘의 활성화 상태를 전역으로 관리해주기 위함이다.
- 즉, 아래와 같이 북마크에 추가된 데이터는 해당 아이템이 어느 페이지에 있든 (메인 페이지, 상품 리스트 페이지, 북마크 페이지) 동일하게 북마크에 추가된 상태의 별 아이콘을 렌더링하게 된다.
코드에 적용하기
로직은 '북마크 아이템' 컴포넌트 안에 구현해주었다. (즉, ⭐️ 아이콘)
BookmarkStar.js
.
.
// 액션을 가져온다
import {
addBookMarkedProducts,
deleteBookMarkedProduct,
} from "../redux/bookmarkReducer";
// 데이터를 꺼내기 위해 useSelector 훅을 가져온다
import { useSelector } from "react-redux";
// dispatch 훅을 가져온다
import { useAppDispatch } from "../redux/hooks";
export default function BookmarkStar({ title, StarRef, id }) {
// bookMarkedProducts에 전역 저장소의 bookMarkedProducts의 값을 할당해서 사용한다.
const bookMarkedProducts = useSelector((store) => store.bookMarkedProducts);
//useAppDispatch를 사용하여 dispatch 함수를 가져온다
const dispatch = useAppDispatch();
const { data, error, isLoading, isFetching } = useGetProductsQuery(null);
let isBookmarked;
const bookMarkedTargetItem = bookMarkedProducts.find(
(product) => product.value.id === id
);
// bookMarkedTargetItem이 있으면 isBookmarked 변수에 해당 값을 할당한다.
if (bookMarkedTargetItem) {
isBookmarked = bookMarkedTargetItem.isBookmarked;
}
const handleBookmarkBtn = (id) => {
// bookMarkedTargetItem이 없으면 해당 상품을 data에서 찾아서
// 추가하기 위해 addBookMarkedProducts 액션을 디스패치한다.
if (!bookMarkedTargetItem) {
const targetItem = data.find((product) => product.id === id);
dispatch(addBookMarkedProducts(targetItem));
// 해당 상품을 삭제하기 위해 deleteBookMarkedProduct 액션을 디스패치한다.
} else {
dispatch(deleteBookMarkedProduct(bookMarkedTargetItem));
}
};
return (
<!--isBookmarked 상태에 따라 아이콘 소스와 클래스명이 바뀐다 -->
<Img
src={isBookmarked ? "/북마크별on.svg" : "/북마크-별표off.svg"}
onClick={(e) => {
handleBookmarkBtn(id);
}}
className={`${title ? "modal" : ""} ${
isBookmarked ? "active" : "inactive"
}`}
alt="북마크"
ref={StarRef}
/>
);
}
2. 토스트 데이터 전역으로 관리하기
북마크 버튼을 누르면 '북마크에 추가되었습니다./ 북마크에서 제거되었습니다' 문구가
3초 정도 뜨고 사라지는 토스트 컴포넌트를 구현했다.
상태는 redux/toolkit을 통해 전역에서 관리된다. 대략 아래와 같이 작성해주면 된다. (store 구축 등 나머지 부분은 생략)
reducer 생성
ToastReducer.js
import { createSlice } from "@reduxjs/toolkit";
const initialState = { notifications: [] };
export const toastReducer = createSlice({
name: "toastReducer",
initialState,
reducers: {
// enqueue 액션은 상태에 새로운 토스트 알림을 추가한다. (새로운 상태를 반환)
enqueue: (state, action) => {
return {
notifications: [...state.notifications, action.payload],
};
},
// dequeue 액션은 상태에서 첫 번재 토스트 알림을 제거한다. (새로운 상태를 반환)
dequeue: (state) => {
return { notifications: state.notifications.slice(1) };
},
},
});
export const { enqueue, dequeue } = toastReducer.actions;
export default toastReducer.reducer;
ToastCenter 생성
ToastCenter은 개별 Toast 컴포넌트의 부모 컴포넌트로, 생기는 Toast의 개수만큼 map 함수를 통해 렌더링해준다.
- 쾌적한 사용자 경험을 위하여 토스트가 7개가 넘어가게 되는 경우에는, 시간이 다 되지 않더라도 사라지도록 기능을 넣어주었다.
ToastCenter.js
"use client";
import Toast from "./Toast";
.
.
import { useSelector } from "react-redux";
import { enqueue, dequeue } from "../redux/ToastReducer";
import { useAppDispatch, useAppSelector } from "../redux/hooks";
export default function NotificationCenter() {
const dispatch = useAppDispatch();
// 전역 저장소에 있는 토스트 알림 데이터를 toast에 담아준다
const toast = useSelector((store) => store.toastReducer.notifications);
// 토스트 알림 데이터가 7개가 쌓이게 되면 오래된 토스트 알림부터 삭제해준다.
if (toast.length === 7) {
dispatch(dequeue());
}
return (
<Container>
{toast.length
? toast.map((n) => (
<Toast
key={n.id}
text={n.message}
dismissTime={n.dismissTime}
/>
))
: null}
</Container>
);
}
코드에 적용하기
로직 구현은 북마크 별 아이콘 컴포넌트 내에 들어있다.
.
.
export default function BookmarkStar({ title, StarRef, id }) {
const bookMarkedProducts = useSelector((store) => store.bookMarkedProducts);
const { data, error, isLoading, isFetching } = useGetProductsQuery(null);
const dispatch = useAppDispatch();
const notify = (message, dismissTime = 3000, id) => { // notify를 먼저 정의해준다.
dispatch(enqueue(message, dismissTime, id)); // 먼저 enqueue 액션을 전달하고,
setTimeout(() => {
dispatch(dequeue()); // default로 설정한 3초 뒤에 dequeue 액션을 전달한다.
}, dismissTime);
};
.
.
const bookMarkedTargetItem = bookMarkedProducts.find( // 이미 북마크가 되어 있는 지 확인한다.
(product) => product.value.id === id
);
const handleBookmarkBtn = (id) => {
if (!bookMarkedTargetItem) { // 만약, 북마크가 되어 있지 않다면
.
.
notify({
message: "북마크에 추가되었습니다.", // 북마크에 추가되었다는 토스트를 띄운다.
dismissTime: 1500,
});
} else {
.
.
notify({
message: "북마크에서 제거되었습니다.",
dismissTime: 1500,
});
}
};
Toast : 자연스럽게 사라지도록 애니메이션 추가하기
개별 토스트 알림을 mount하고, 일정 시간이 지나면 unmount하는 컴포넌트이다.
- useEffect를 활용하여 className을 조건부 스타일링 하는 방법을 주었다.
- 동작 방식은 토스트가 뜬 다음, 2초 뒤부터 오른쪽 화면 밖으로 이동하는 애니메이션이 작동되며 3초 뒤에 완전히 사라진다.
- 즉, 여러 토스트 컴포넌트(아래 참조)가 차례로 mount 되면서 다시 unmount되는 구조이다.
- 아래에서부터 시작해서, 새로운 토스트들이 위로 쌓이게 하기 위해서 Toast 컴포넌트의 부모 컴포넌트에 flex-direction: column-reverse 와 justify-content: flex-start 를 주었다.
Toast.js
.
.
export default function Toast({ text, dismissTime }) {
const [isFading, setIsFading] = useState(false);
useEffect(() => {
let mounted = true;
setTimeout(() => {
if (mounted) {
setIsFading(true);
}
}, dismissTime);
return () => {
mounted = false;
};
}, []);
return (
<MessageContainer className={`${isFading ? "fadeOut" : ""}`}>
<h4 className="text-myBlue">{text}</h4>
</MessageContainer>
);
}
.
.
사소한 문제 발생 및 해결
Before
- 토스트를 연속으로 띄운 후, 다시 또 띄우게 되면 flex를 사용하여 순서대로 나오고, 사라지도록 구현하고자 했지만 연속해서 클릭했을 때 레이아웃이 깨지는 문제와 사라지는 토스트에 맞춰 새로 생성된 토스트가 함께 바로 사라지는 문제가 생겼다.
- 해결 시도 1) CSS transition이나 flex 속성 등을 바꿔보았다. -> 실패
- 해결 시도 2) useEffect 의존성 배열을 변경하여 새로운 토스트가 나타날 때 이전 토스트의 효과가 영향을 받지 않고자 했다. -> 실패했으나 각자 mount 되는 토스트 컴포넌트들이 독립적이지 않고, 엉켜있는 게 문제라는 것을 깨달았다.
의외로 해결 방법은 간단했다.
- 원인은 toast 배열을 map을 돌릴 때, key 값으로 index를 주고 있기 때문이었다.
- 특히, 토스트의 경우, 앞의 배열의 아이템부터 삭제되는 구조이기 때문에 key 값이 index가 되면 로직 전체가 엉킬 수 밖에 없게 된다.
- 포스팅 : 배열의 index를 키 값으로 주게 될 경우 문제
return (
<Container>
{toast.length
? toast.map((n) => (
<Toast
key={n.id} // 기존에는 id가 index 였다.
text={n.message}
dismissTime={n.dismissTime}
/>
))
: null}
</Container>
);
}
After
따라서 shortId 라이브러리를 설치하여, 고유한 아이디를 생성해주었다. -> 해결!
import shortid from "shortid"; // shortid 라이브러리를 설치하여 가져왔다.
.
.
const bookMarkedTargetItem = bookMarkedProducts.find(
(product) => product.value.id === id
);
const handleBookmarkBtn = (id) => {
if (!bookMarkedTargetItem) {
.
.
notify({
message: "북마크에 추가되었습니다.",
dismissTime: 1500,
id: shortid.generate(), // 아이디 생성
});
} else {
.
.
notify({
message: "북마크에서 제거되었습니다.",
dismissTime: 1500,
id: shortid.generate(), // 아이디 생성
});
}
};
빨간색 테두리 (ToastCenter) 컴포넌트 안에 토스트(Toast) 컴포넌트가 순서대로 나오고, 3초 뒤에 들어간다.
- 의도한 동작 디테일 )
- 2초 뒤에 오른쪽 화면 밖으로 사라진다.
- 바로 앞의 토스트가 사라졌을 때, 밑으로 내려가서 내려간 자리 그대로 진행 중이던 애니메이션이 끊기지 않고 계속 진행된다.
정말 토스트 애니메이션을 붙잡고 몇 시간을 잡고 있었는데 결국 해결책은 너무나도 기본적이 것이어서 한편으로는 허무했다.
'안 되는 건가?' 라고 생각하며 넘기려던 순간, 침대에 누워 토스트 라이브러리 gif 이미지를 구글링해보면서
문제 없이 내가 원하던 사소한 동작이 잘 구현된 걸 보고 벌떡 일어나
'방법은 무조건 있다!' 라는 마인드로 한 번 더 들여다보지 않았더라면 그냥 묻어뒀을 사소한 동작일 수도 있다.
하지만, 무엇이든 원리를 정확히 숙지하고 있어야 한다는 교훈도 얻었다.
map을 돌릴 때 key 값을 최대한 사용하지 않아야 한다, 는 건 알았지만
'왜 그렇게 하면 안 되지?' 에 대한 개념이 장착되지 않았었기 때문에
이런 간단한 로직에서도 원인을 찾지 못한 것이기 때문이다.
[오늘의 교훈]
1. 두드려라! 그러면 언젠가는 열릴 것이다.
2. '왜 그렇게 하면 안 되지?' 에 대한 대답을 늘 찾아서 숙지하기!
'📌 TOY-PROJECT > 2305 쇼핑몰 웹앱 (북마크 기능)' 카테고리의 다른 글
5일 간의 솔로 프로젝트 마무리 : KPT 회고 (0) | 2023.06.08 |
---|---|
README 작성하기 (0) | 2023.06.07 |
RTK Query (0) | 2023.05.15 |
헤더 컴포넌트의 DropDown 메뉴 만들기 (0) | 2023.05.14 |
Next.js 13 - StackoverFlow에 질문글을 올리다. (1) | 2023.05.12 |