DREAMCARD_후기좋아요_TanstackQuery(커스텀훅)_낙관적 업데이트_테이블분리저장

정소현·2024년 11월 15일
0

팀프로젝트

목록 보기
44/50

사용자들이 작성한 후기들을 볼 수 있는 후기조회페이지를 만든 후
다른 유저들이 후기에 도움돼요(좋아요)를 달 수 있는 기능을 만들려고 한다.

☘️ SUPABASE에 셋팅하기

  • 좋아요가 눌러진 정보를 supabase에 저장하여 나중에 좋아요 순으로 정렬하는 등 사용하기 위해 추가로 table을 만들어주었다.
  • 한 사용자가 동일한 게시물에는 여러번 좋아요를 누를 수 없고 , 다양한 사용자들은 한 게시물에 좋아요를 누를 수 있어야한다.

💥 나는 주로 dashboard에서 주로 column설정을 했지만
지금 같이 복합 고유 제약이 있을경우는 sql문을 사용한다.

sql문

alter table "likeReview"
add constraint unique_user_post unique (user_id, post_id);

likeReview 테이블 구조 설계

user_id와 post_id가 각각 고유해야 하며, 이 둘의 조합만 고유하도록 제약을 설정 즉, 복합 고유 제약을 설정하여 한 사용자가 특정 게시물에 중복된 좋아요를 누를 수 없게 만든다.

TABLE COLUMN

  • post_id : UUID
  • user_id : UUID
  • id : UUID
  • created_at : timestamp

foreign Key(외래키 사용)
: 후기 테이블과 후기좋아요 테이블을 연결시켜주어 후기가 삭제되면 좋아요 정보도 함꼐 삭제되도록 처리

  1. 데이터 무결성 유지:

    : likes 테이블에서 post_id와 user_id가 각각 posts 테이블과 users 테이블에 실제로 존재하는 값인지 확인할 수 있다.
    잘못된 데이터(예: 삭제된 후기나 존재하지 않는 사용자 ID에 대한 좋아요)를 저장하는 것을 방지함

  2. 자동 삭제 (Cascade Delete):

: posts 테이블의 후기가 삭제되면, 해당 후기에 연결된 likes 테이블의 좋아요 기록도 자동으로 삭제할 수 있다.
이를 통해 데이터 정리를 자동으로 처리할 수 있다.

  1. 데이터 일관성 보장:

: 사용자나 후기가 삭제되었을 때, 관련된 모든 좋아요 데이터도 함께 삭제됨으로써 데이터베이스 내 일관성을 유지할 수 있다.


SUPABASE 에서 좋아요 누른 데이터 전부 가져오기

export const getAllReviewLike = async (userId: string) => {
  const { data, error } = await supabase.from('likeReview').select('*').eq('user_id', userId);

  if (error) throw error;
  return data;
};

SUPABASE 좋아요 추가

export const addReviewLike = async (postId: string, userId: string) => {
  const { data: insertData, error } = await supabase.from('likeReview').insert([{ post_id: postId, user_id: userId }]);

  if (error) throw error;

  return insertData;
};

SUPABASE 좋아요 삭제


export const removeReviewLike = async (postId: string, userId: string) => {
  const { data, error } = await supabase.from('likeReview').delete().eq('post_id', postId).eq('user_id', userId);
  if (error) throw error;

  return data;
};

☘️ supabase 추가, 삭제, 데이터 불러오는 로직 작성

// src > utils > getReview.ts

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addReviewLike, removeReviewLike } from '@/utils/getReview';
import { QUERY_KEYS } from '../queryKeys';
import { Notify } from 'notiflix';

export const useReviewAddLikeMutation = ({ userId, postId }: { userId: string; postId: string }) => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: () => addReviewLike(userId, postId),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.likeReview() });
      Notify.success('도움돼요가 추가되었습니다.');
    },
  });
};

export const useReviewRemoveLikeMutation = ({ userId, postId }: { userId: string; postId: string }) => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: () => removeReviewLike(userId, postId),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.likeReview() });
      Notify.success('도움돼요가 취소되었습니다.');
    },
  });
};

🔥 ** 커스텀 훅의 사용 (TanstackQuery useMutation, QueryClient를 활용하여 좋아요 & 삭제) & 낙관적 업데이트

  • 팀원들끼리 코드의 가독성과 유지보수를 위해 queryKey를 분리하고
    tanstackQuery를 사용하는 부분은 커스텀훅을 만들고 Fn부분도 분리하여 사용하기로 하였다.
  • 나는 사용자 경험을 중요시하기 때문에 좋아요를 사용자들이 누르고 빠른 변화를 경험할 수 있도록 낙관적 업데이트를 진행하였다.

🌟 낙관적 업데이트

  • TanstackQuery의 주요기능 중 하나인 invalidatequeries 활용한 캐시무효화로 낙관적 업데이트 진행
  • 요청을 실패 할 시 자동으로 최신 데이터를 가져온다.
  • 캐시 무효화만 활용:

과정

  • onSuccess에서 invalidateQueries를 사용하여 좋아요 데이터를 새로고침
  • 서버에 요청이 성공하면 자동으로 UI가 최신 상태로 업데이트

// src > hook > queries > review > useReviewLike.ts

데이터 추가 mutation

type LikeMutationVariables = {
  postId: string;
  signedUserId: string;
};

export const useReviewAddLikeMutation = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ postId, signedUserId }: LikeMutationVariables) => addReviewLike(postId, signedUserId),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.likeReview() });
      Notify.success('도움돼요가 추가되었습니다.');
    },
    onError: () => {
      Notify.failure('도움돼요 반영에 실패했습니다. 다시 시도해주세요.');
    },
  });
};

데이터 삭제 mutation

export const useReviewRemoveLikeMutation = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ postId, signedUserId }: LikeMutationVariables) => removeReviewLike(postId, signedUserId),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.likeReview() });
      Notify.success('도움돼요가 취소되었습니다.');
    },
    onError: () => {
      Notify.failure('도움돼요 취소에 실패했습니다. 다시 시도해주세요.');
    },
  });
};

사용자가 좋아요 누른 데이터 전부 가져오기

export const useGetAllReviewsLikeQuery = (userId: string) => {
  return useQuery({
    queryKey: QUERY_KEYS.likeReview(),
    queryFn: () => getAllReviewLike(userId),
  });
};

☘️ ReviewCard.tsx에서 로직사용

  • 나는 ReviewCard.tsx에서 후기 한칸을 컴포넌트화시켜 받아온 Reviews데이터들을 map함수를 이용해 ReviewItem.tsx로 만들었다.
    상위컴포넌트인 ReviewCard.tsx에서 개별적으로 post_id를 받을 수 있도록 로직을 작성했다.
  • 현재 page.tsx(review) 에서 user를 받고있지만 여기서 넘어오는 user정보는 ReviewItem에서 사용자들의 정보를 보여주기 위해 받아온 모든 유저들의 정보를 담고 있는 user임으로 구분하여 현재 접속한 유저의 정보의 id를 가져올 수 있도록 useQuery를 사용하여 유저 데이터를 불러왔다.

// src > hooks > queries > useSignedAuthUser.ts

import { useQuery } from '@tanstack/react-query';
import { QUERY_KEYS } from './queryKeys';
import { getAuthUser } from '@/utils/getAuthUser';

export const useSignedAuthUser = () => {
  return useQuery({
    queryKey: QUERY_KEYS.loginUser(),
    queryFn: getAuthUser,
  });
};

// src > utils > getAuthUser.ts

import browserClient from '@/utils/supabase/client';

export const getAuthUser = async () => {
  const { data, error } = await browserClient.auth.getUser();
  if (error) {
    throw new Error('유저 정보를 가져오는 데 실패했습니다.');
  }

  return data.user || '';
};

ReviewCard.tsx에서의 사용 방법

  • 하위 컴포넌트 내에 있는 도움돼요 버튼을 한 번누르면 true 한 번 더 누르면 false가 되어야했기 때문에 toggle형태가 필요했다. 맨 처음 도움돼요가 눌러져있는지 정보를 가져와서 확인 후에 상태를 바꿔주어야 했다.
const handleLikeToggle = (postId: string, isLiked: boolean) => {
    if (!signedUserId) {
      Notify.failure('로그인 후 이용해주세요.');
      return;
    }
    if (!isLiked) {
      mutationAddLike.mutate({ postId, signedUserId });
    } else {
      mutationDeleteLike.mutate({ postId, signedUserId });
    }
  };
  • if문으로
    isLiked가 되어있지 않으면 도움돼요로 설정하는 로직을
    islied면 도움돼요를 삭제하는 로직을 작성해주었다.
  • 개별적으로 하위 컴포넌트에서 버튼을 눌러 toggle할 수 있도록 props를 이용해 handleLikeToggle()함수를 전달해주었다.
const ReviewCard = ({ reviews }: ReviewsCardProp) => {
  const router = useRouter();
  const [expandedReview, setExpandedReview] = useState<string | null>(null);

  const { data: users, isLoading, error } = useAuthUserQuery();
  const { data: currentUser } = useSignedAuthUser();

  const signedUserId = currentUser?.id || '';

  const mutationAddLike = useReviewAddLikeMutation();
  const mutationDeleteLike = useReviewRemoveLikeMutation();
  const { data: userLikeReview } = useGetAllReviewsLikeQuery(signedUserId);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>오류 발생</div>;

  const handleLikeToggle = (postId: string, isLiked: boolean) => {
    if (!signedUserId) {
      Notify.failure('로그인 후 이용해주세요.');
      return;
    }
    if (!isLiked) {
      mutationAddLike.mutate({ postId, signedUserId });
    } else {
      mutationDeleteLike.mutate({ postId, signedUserId });
    }
  };

  const handleReviewDetail = (id: string) => router.push(`/review/${id}`);

  const toggleContent = (id: string) => setExpandedReview((prev) => (prev === id ? null : id));

  return (
    <div>
      {reviews.map((review) => {
        const user = users?.users.find((u: User) => u.id === review.user_id);
        if (!user) return <div key={review.id}>사용자를 찾을 수 없습니다.</div>;

        const isLiked = userLikeReview?.find((likeReview) => likeReview.post_id === review.id);
        return (
          <ReviewItem
            key={review.id}
            review={review}
            user={user}
            isExpanded={expandedReview === review.id}
            onToggle={() => toggleContent(review.id)}
            onNavigate={() => handleReviewDetail(review.id)}
            isLiked={isLiked}
            onLikeToggle={() => handleLikeToggle(review.id, isLiked)}
          />
        );
      })}
    </div>
  );
};

export default ReviewCard;

☘️ ReviewItem.tsx

ReviewCard.tsx에서 ReviewItem.tsx로 내려준 onLikeToggle함수를 받아 버튼에 onClick으로 넣어주었다.
그리고 선택된 버튼들은 색상변경을 적용시켜주기 위해 상위 컴포넌트에서
isLiked 정보를 받아와 false일 때와 true 일 때를 구분시켜주었다.

const ReviewItem = ({
  review,
  user,
  isExpanded,
  onToggle,
  onNavigate,
  onLikeToggle,
  isLiked,
}: {
  review: Review;
  user: User;
  isExpanded: boolean;
  onToggle: () => void;
  onNavigate: () => void;
  onLikeToggle: () => void;
  isLiked: boolean;
}) => {
  ~~~
    
  <button
              onClick={onLikeToggle}
              className={`text-[12px] flex items-center justify-center border-primary-300 border-[1px] border-solid rounded-[90px] w-[76px] h-[24px] text-primary-300 ${isLiked && 'bg-primary-300'} `}
            >
              <img
                src={'/assets/images/icons/smiley-happy.svg'}
                alt='도움돼요'
              />
              <span>도움돼요</span>
            </button>
          </>

🔥 트러블 슛팅 & 회고

팀의 기능우선개발순서에 따라 다른 페이지를 작업하다가 다시 후기페이지의 작업을 진행하게 되었다. 작업 당시 컴포넌트를 분리하고 코드 가독성을 높이기 위해 노력하였고 휴먼에러를 방지하기 위해 키들을 분리시켰다.

1달이라는 기간을 잡고 진행하는 프로젝트인 만큼 많은 기능들을 개발하고 여러 페이지들을 담당하고 다시 초반에 작업했던 페이지로 돌아와 작업을 해보니 다시 한번 코드의 가독성이 떨어지면 유지보수가 힘들어진다는 것이 어떤 것인지 느끼게 되었다.

실제로 오랜만에 후기 페이지를 작성하다보니 상위컴포넌트에서 작성해야할 로직을 하위 컴포넌트에서 오랜시간동안 로직작성을 하고 있었다.

추후에 곧바로 알게되었지만 내가 작성한 코드임에도 이런 실수를 할 수 있다는 것에서 기능을 개발하고 페이지를 만드는 것은 단순한 기술과 눈에 보여지는 것이 전부가 아니라 연쇄적으로 연결되어있고, 짜임새있는 기획과 코드 전체를 보고 풀어나가는 것이 중요한 것인지 깨달았다.

0개의 댓글