본문 바로가기

redux/toolkit(RTK) / 토스트 애니메이션 자연스럽게 구현하기

redux/toolkit(RTK) / 토스트 애니메이션 자연스럽게 구현하기
Next.js 13과 redux/toolkit, styled-components 등으로 북마크 기능이 들어있는 쇼핑몰 앱을 구현한 프로젝트이며, 설명에 필요한 코드만 최소한으로 서술되었습니다. :
(1) gitHub - README 및 전체 코드 보러 가기   
(2) 프로젝트 요구 명세서 보기 (해당 프로젝트 첫 포스팅)
 

GitHub - Doyu-Lee/COZ-shopping: This project consists of a main page, a product list page, and a bookmark page, offering respons

This project consists of a main page, a product list page, and a bookmark page, offering responsive design and features like random and bookmarked product display, filtering, modal windows, bookmar...

github.com

 


 

저번 포스팅에서는 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 되는 토스트 컴포넌트들이 독립적이지 않고, 엉켜있는 게 문제라는 것을 깨달았다.

 

의외로 해결 방법은 간단했다.

  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. '왜 그렇게 하면 안 되지?'  에 대한 대답을 늘 찾아서 숙지하기! 

 

728x90
⬆︎