사용자들이 작성한 후기들을 볼 수 있는 후기조회페이지를 만든 후
다른 유저들이 후기에 도움돼요(좋아요)를 달 수 있는 기능을 만들려고 한다.
💥 나는 주로 dashboard에서 주로 column설정을 했지만
지금 같이 복합 고유 제약이 있을경우는 sql문을 사용한다.
sql문
alter table "likeReview"
add constraint unique_user_post unique (user_id, post_id);
user_id와 post_id가 각각 고유해야 하며, 이 둘의 조합만 고유하도록 제약을 설정 즉, 복합 고유 제약을 설정하여 한 사용자가 특정 게시물에 중복된 좋아요를 누를 수 없게 만든다.
★ TABLE COLUMN
★ foreign Key(외래키 사용)
: 후기 테이블과 후기좋아요 테이블을 연결시켜주어 후기가 삭제되면 좋아요 정보도 함꼐 삭제되도록 처리
데이터 무결성 유지:
: likes 테이블에서 post_id와 user_id가 각각 posts 테이블과 users 테이블에 실제로 존재하는 값인지 확인할 수 있다.
잘못된 데이터(예: 삭제된 후기나 존재하지 않는 사용자 ID에 대한 좋아요)를 저장하는 것을 방지함
자동 삭제 (Cascade Delete):
: posts 테이블의 후기가 삭제되면, 해당 후기에 연결된 likes 테이블의 좋아요 기록도 자동으로 삭제할 수 있다.
이를 통해 데이터 정리를 자동으로 처리할 수 있다.
: 사용자나 후기가 삭제되었을 때, 관련된 모든 좋아요 데이터도 함께 삭제됨으로써 데이터베이스 내 일관성을 유지할 수 있다.
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;
};
// 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('도움돼요가 취소되었습니다.');
},
});
};
// 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),
});
};
// 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 || '';
};
const handleLikeToggle = (postId: string, isLiked: boolean) => {
if (!signedUserId) {
Notify.failure('로그인 후 이용해주세요.');
return;
}
if (!isLiked) {
mutationAddLike.mutate({ postId, signedUserId });
} else {
mutationDeleteLike.mutate({ postId, signedUserId });
}
};
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;
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달이라는 기간을 잡고 진행하는 프로젝트인 만큼 많은 기능들을 개발하고 여러 페이지들을 담당하고 다시 초반에 작업했던 페이지로 돌아와 작업을 해보니 다시 한번 코드의 가독성이 떨어지면 유지보수가 힘들어진다는 것이 어떤 것인지 느끼게 되었다.
실제로 오랜만에 후기 페이지를 작성하다보니 상위컴포넌트에서 작성해야할 로직을 하위 컴포넌트에서 오랜시간동안 로직작성을 하고 있었다.
추후에 곧바로 알게되었지만 내가 작성한 코드임에도 이런 실수를 할 수 있다는 것에서 기능을 개발하고 페이지를 만드는 것은 단순한 기술과 눈에 보여지는 것이 전부가 아니라 연쇄적으로 연결되어있고, 짜임새있는 기획과 코드 전체를 보고 풀어나가는 것이 중요한 것인지 깨달았다.