최종 프로젝트 (Voyage-X)

최종 프로젝트 - Optimistic update로 찜 기능 구현

고래고래00 2024. 8. 4. 00:49

찜 / 찜 취소 기능 구현

트리거를 연결해준 덕에 그냥 상품좋아요 테이블에 행 넣기만 하면 끝

useMutation으로 optimistic update를 적용시킬 거다.

optimistic update란 쉽게 말해 아님말고식 업데이트

좋아요로 예를 들면 좋아요를 누르지 않은 상태에서 좋아요 버튼 클릭하면 일단 좋아요 누른 것 처럼 보이게하고

서버에 요청을 보낸다.

요청에 성공해 서버와 클라이언트의 상태가 같으면 (둘 다 좋아요한 상태) 그대로 두고

다르다면 (클라이언트는 좋아요한 상태인데 서버에서는 에러가 발생해 좋아요가 반영되지 않음)

다시 원래 상태로 돌려놓는 => 아님말고

 

일단 유저의 상품에 대한 좋아요 여부부터 판단하기

라우트 핸들러

/api/goods/[goods_id]/like/route.ts

나는 상품별로 좋아요를 유저가 눌렀는지를 확인하기 위해 위와 같이 경로 설정했고

유저정보는 쿼리스트링으로 받아오려했다.

export const GET = async (request: Request, { params }: ParamsType) => {
  const { goods_id } = params;
  const { searchParams } = new URL(request.url);
  const user_id = searchParams.get('user_id');
  if (!user_id) return NextResponse.json({ ereor: '로그인 해주세요.' });
  const { data, error } = await supabase
    .from('liked_goods')
    .select('*')
    .match({ goods_id, user_id });
  if (error) return NextResponse.json({ error });
  return NextResponse.json(!!data.length);
};

처음에는 행 전체를 가져왔다가 optimistic update를 적용시키려면 불린타입으로 가져오는게 더 좋겠다고 생각했다.

Supabase는 해당 데이터가 없으면 빈 배열로 반환해서 length 속성을 이용했다.

 

서비스 함수

export const getIsLikedGoodsByUser = async (
  goods_id: string,
  user_id: string,
) => {
  const response = await axios.get(
    `/api/goods/${goods_id}/like?user_id=${user_id}`,
  );
  return response.data;
};

유저별 해당 상품에 대한 좋아요여부를 판단하는 서비스 함수

유저 정보를 쿼리스트링으로 보내준다.

 

커스텀 훅

const useGetIsLikedGoodsByUser = (goods_id: string, user_id: string) => {
  return useQuery<boolean>({
    queryKey: ['like', goods_id, user_id],
    queryFn: () => getIsLikedGoodsByUser(goods_id, user_id),
  });
};

굿즈별로 유저별로 좋아요여부를 확인하기위해 쿼리키를 저렇게 설정했다.

 

Hearts 컴포넌트 (좋아요 컴포넌트)

// Hearts.tsx

...

const {
    data: isLiked,
    isError,
    isPending,
  } = useGetIsLikedGoodsByUser(goods_id, user_id);
  
  ...
  
  {isLiked ? <HeartPressedIcon32px /> : <HeartDefaultIcon32px />}

 

isLike가 true라면 좋아요 버튼이 꽉 찬 하트가 되고 클릭시 좋아요가 취소되도록

false라면 빈 하트 버튼에 클릭하면 좋아요가 되도록 할 거다.

 

좋아요 여부에 따른 찜 / 찜 취소 기능

라우트 핸들러

// 찜 (굿즈좋아요 테이블에 행 추가)
export const POST = async (request: Request, { params }: ParamsType) => {
  const { goods_id } = params;
  const { searchParams } = new URL(request.url);
  const user_id = searchParams.get('user_id');
  if (!user_id)
    return NextResponse.json({ error: '유저id를 받지 못했습니다.' });
  const { data, error } = await supabase.from('liked_goods').insert({
    goods_id,
    user_id,
  });
  if (error) return NextResponse.json({ error });
  return NextResponse.json(data);
};

// 찜 취소 (굿즈좋아요 테이블에 행 삭제)
export const DELETE = async (request: Request, { params }: ParamsType) => {
  const { goods_id } = params;
  const { searchParams } = new URL(request.url);
  const user_id = searchParams.get('user_id');
  if (!user_id)
    return NextResponse.json({ error: '유저 id를 받지 못했습니다.' });
  const { data, error } = await supabase
    .from('liked_goods')
    .delete()
    .match({ goods_id, user_id });
  if (error) return NextResponse.json({ error });
  return NextResponse.json(data);
};

 

서비스 함수

export const toggleLikeGoods = async (
  toggleLikeGoodsParams: toggleLikeGoodsParamsType,
) => {
  const { goods_id, user_id, isLiked } = toggleLikeGoodsParams;
  // 찜한 상태라면 DELETE 요청
  if (isLiked) {
    const response = await axios.delete(
      `/api/goods/${goods_id}/like?user_id=${user_id}`,
    );
    console.log(response);
    return response;
  } else {	// 그게 아니라면 POST 요청
    const response = await axios.post(
      `/api/goods/${goods_id}/like?user_id=${user_id}`,
    );
    console.log(response);
    return response;
  }
};

 

커스텀 훅 (useMutation, Optimistic update)

export const useToggleLikeGoods = (
  goods_id: string,
  user_id: string,
  isLiked: boolean,
) => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: toggleLikeGoods,
    onMutate: async ({ goods_id, user_id, isLiked }) => {
      await queryClient.cancelQueries({
        queryKey: ['like', goods_id, user_id],
      });
      const previousHeart = queryClient.getQueryData([
        'like',
        goods_id,
        user_id,
      ]);
      queryClient.setQueriesData(
        { queryKey: ['like', goods_id, user_id] },
        !isLiked,
      );
      return { previousState: previousHeart };
    },
    onError: (err, isLiked, context) => {
      queryClient.setQueriesData(
        { queryKey: ['like', goods_id, user_id] },
        context?.previousState,
      );
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['like', goods_id, user_id] });
    },
  });
};

 

1. onMutate

onMutate: async ({ goods_id, user_id, isLiked }) => {
      await queryClient.cancelQueries({
        queryKey: ['like', goods_id, user_id],
      });
      const previousHeart = queryClient.getQueryData([
        'like',
        goods_id,
        user_id,
      ]);
      queryClient.setQueriesData(
        { queryKey: ['like', goods_id, user_id] },
        !isLiked,
      );
      return { previousState: previousHeart };
    },

일단 기존 좋아요 상태에서 반대로 적용시키고본다.

찜 X => 찜O, 찜 O => 찜 X

그리고 이전의 상태를 previousState에 저장해둔다.

 

2. mutateFn

mutationFn: toggleLikeGoods,

toggleLikeGoods 서비스 함수를 이용해 서버에 요청을 보낸다.

 

3 onSettled

onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['like', goods_id, user_id] });
    },

서버에서 성공했다는 응답을 보내면 기존 쿼리 무효화 시킨다.

 

4. onError

onError: (err, isLiked, context) => {
      queryClient.setQueriesData(
        { queryKey: ['like', goods_id, user_id] },
        context?.previousState,
      );
    },

망했다면 원래의 상태를 저장해뒀던 previousState로 돌려놓는다.

 

이제 Hearts 컴포넌트에 적용

...
const { mutate: likeMutate } = useToggleLikeGoods(
    goods_id,
    user_id,
    isLiked!,
  );

  const handleToggleLike = () => {
    if (isLiked === undefined) return;
    const toggleParams: toggleLikeGoodsParamsType = {
      goods_id,
      user_id,
      isLiked,
    };
    likeMutate(toggleParams);
  };
  
  ...

좋아요 버튼을 클릭하면 mutate가 실행된다.

 

결과

빈 하트를 누르면 일단 하트를 꽉 채우고보고 요청을 보낸다.

처음으로 optimistic update를 적용시켜봤는데 생각한대로 잘 된다.

굿