개요
저번 프로젝트에서 북마크 기능을 구현해본 경험이 있었다.
하지만 그때는 마감시간에 급박하게 짜느라 코드를 깔끔하게 작성하지 못했다.
또한, 구현했던 내용을 정리하지 못해 잘 기억도 나지 않았다.
이번에는 잊지 않기 위해 북마크 기능에 대한 내용을 정리해보려고 한다.
북마크 기능 구현 환경 및 순서
아웃소싱 프로젝트에서는 BaaS(Backend as a Service)인 supabase와 Tanstack Query를 사용했다.
프론트엔드에만 집중하기 위하여 supabase를 이용하였고, 서버 상태를 관리하기 위하여 Tanstack Query를 선택하였다.
🚩 북마크 기능 구현의 순서
1. supabase에 bookmarks 테이블 만들기
2. bookmarks 테이블에서 사용자가 북마크한 데이터 가져오기
3. 북마크 추가 기능 구현하기
4. 북마크 삭제 기능 구현하기
5. 북마크 추가/삭제 기능 적용하기
순서에 따라 차근차근 진행해보겠다.
참고로 북마크 기능과 좋아요 기능은 동일하기 때문에 좋아요 기능을 구현할 때도 이 글을 참고해도 좋다.
1️⃣ supabase에 bookmarks 테이블 만들기
Supabase | The Open Source Firebase Alternative
Build production-grade applications with a Postgres database, Authentication, instant APIs, Realtime, Functions, Storage and Vector embeddings. Start for free.
supabase.com
자신이 생성한 프로젝트에 들어가면 테이블을 제작할 수 있다.
bookmarks 테이블의 column은 다음과 같다.
`id` : 북마크 아이디
`created_at` : 북마크한 시간
`job_id` : 채용 정보 아이디
`user_id` : 북마크한 사용자 아이디
`job_id`와 `user_id`는 FK(Foriegn Key)로 연결되어 있다.
하고 있는 프로젝트의 정보에 따라 `job_id`가 달라질 것이다.
job_id를 연결해 둔 이유: 어떤 채용 정보를 북마크했는지 알기 위해서
user_id를 연결해 둔 이유: 누가 해당 채용 정보를 북마크했는지 알기 위해서
2️⃣ bookmarks 테이블에서 사용자가 북마크한 데이터 가져오기
사용자가 북마크한 채용 정보를 보여주기 위해서
bookmarks 테이블에서 사용자가 북마크한 데이터를 가져와야 한다.
나는 bookmarks 테이블에서만 정보를 가져올 수 있는 줄 알았는데, 꼭 그것만은 아니기도 하다.
job_id와 연결되어 있기 때문에, jobs 테이블 데이터를 가져올 때 bookmarks 테이블을 연결시켜서 확인할 수도 있다.
2가지 방법 모두 설명하겠다.
1. bookmarks 테이블에서 데이터 가져오기
💻 fetchuserBookmark.js 코드
import supabase from '../supabase/client';
export const fetchUserBookmark = async (userId) => {
try {
const { data: bookMarkData, error } = await supabase
.from('bookmarks')
.select('*')
.eq({ user_id: userId });
if (error) throw error;
return bookMarkData || [];
} catch (error) {
console.error('북마크 조회 오류', error);
}
};
로그인한 사용자 아이디에 맞춰 supabase에서 북마크한 정보를 가져오는 코드이다.
북마크한 정보가 없을 수도 있기 때문에, 없으면 빈 배열을 주는 로직도 추가하였다.
💻 useBookmarksQuery.js 코드
import { useQuery } from '@tanstack/react-query';
import { QUERY_KEY } from '../constants/queryKeys';
import { fetchUserBookmarks } from '../api/bookmarks';
import useAuthStore from '../zustand/useAuthStore';
export const useBookmarksQuery = () => {
const userId = useAuthStore((state) => state.user.user_id);
return useQuery({
queryKey: [QUERY_KEY.BOOKMARKS],
queryFn: () => fetchUserBookMarks(userId),
});
추가적으로 Tanstack Query를 이용해서 데이터를 조회하는 로직을 짰다.
그냥 위의 코드만 사용하면 안되는가?라는 생각이 들 수 있는데, 당연히 된다.
위의 코드만 사용하려면 하려면, 코드를 사용하려는 컴포넌트에서 useEffect를 이용해서 데이터를 가져오면 된다.
다만, 데이터가 로딩 중일 때 혹은 데이터 가져오는 것을 실패했을 때의 로직을 알아서 짜야 한다.
Tanstack Query를 이용하면 어떤 이점이 있을까?
크게 데이터 캐싱, 자동 리패칭, 쿼리 무효화가 있다. 자세한 내용을 다루지 않기 때문에 따로 찾아보길 권한다.
bookmarks 데이틀에서 데이터를 조회할 때 Tanstack Query의 `useQuery`를 이용하면,
userQuery 안에 있는 `isPending`, `isError` 을 이용해서 코드를 짤 수 있다!
const { data: jobData, isPending, isError } = useBookmakrsQuery();
// 데이터가 로딩 중일 때 UI
if (isPending) return <LoadingPage state="load" />;
// 데이터를 불러오는 데 실패했을 때 UI
if (isError) return <LoadingPage state="error" />;
추가적으로 설명을 덧붙이자면, 저 모든 로직을 한 곳에서 적어도 된다.
컴포넌트에서 비즈니스 로직을 분리하기 위해 `useBookmarksQuery`라는 커스텀 훅을 제작한 것이다.
2. jobs 테이블에서 데이터 가져오기
Tanstack Query에 관한 내용은 위에서 설명했기 때문에 여기에서는 코드만 간단하게 보여주겠다.
💻 fetchJobsData 코드
import supabase from '../supabase/client';
export const fetchJobsData = async (table1) => {
try {
const { data } = await supabase
.from(table1)
.select('*, resumes(*), bookmarks(*)');
return data || [];
} catch (error) {
console.error('fetching error', error);
}
};
jobs 테이블에서 채용 정보를 가져올 때 bookmarks에 테이블에 대한 정보도 함께 가져온다.
이렇게 가져오면, 각각의 채용 정보마다 배열 안에 사용자가 북마크한 데이터가 들어있다.
💻 useJobsQuery 코드
export const useJobsQuery = () => {
return useQuery({
queryKey: [QUERY_KEY.JOBS],
queryFn: () => fetchJobsData(QUERY_KEY.JOBS),
});
};
`useJobsQuery`를 이용해서 jobs 데이터를 가져온 뒤에, `filter` 메서드를 이용해서 북마크 정보에 사용자 아이디가 있는 경우를 필터링해주었다.
import { useJobsQuery } from '../../../hooks/useJobsQuery';
import useAuthStore from '../../../zustand/useAuthStore';
const userId = useAuthStore((state) => state.user.user_id);
const { data: jobData, isPending, isError } = useJobsQuery();
/** 사용자가 북마크한 채용 정보 */
const bookmarkedJobs = jobData?.filter(
(job) =>
job.bookmarks.length !== 0 &&
job.bookmarks.some((bookmark) => bookmark.user_id === userId),
);
자 이렇게 가져온 북마크 데이터를 자신의 입맞에 맞춰 이용하면 된다.
3️⃣ 북마크 추가 기능 구현하기
북마크 추가와 삭제 기능은 생각보다 간단하다.
(물론 간단하게 만들기 위해서... 수많은 오류 해결과 리팩토링의 과정이 있었다)
북마크 추가 기능 먼저 알아보자.
북마크 추가는 supabase의 `insert`를 이용하면 된다.
💻 createBookmark.js 코드
import { QUERY_KEY } from '../constants/queryKeys';
import supabase from '../supabase/client';
export const createBookmark = async ({ jobId, userId }) => {
try {
const { error } = await supabase
.from(QUERY_KEY.BOOKMARKS)
.insert([{ job_id: jobId, user_id: userId }]);
if (error) throw error;
} catch (error) {
console.error('북마크 추가 오류', error);
}
};
userId와 jobId를 인자로 받아서 bookmarks 테이블에 해당 데이터를 넣어주면 된다.
💻 useCreateBookmarkMutation.js 코드
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createBookmark } from '../../api/bookmarks';
import { QUERY_KEY } from '../../constants/queryKeys';
export const useCreateBookmarkMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createBookmark,
onSuccess: queryClient.invalidateQueries([QUERY_KEY.BOOMARKS]),
});
};
이후 `useMutation` 커스텀 훅을 만들어 주었다.
기존의 로직에는 Optimistic Update를 적용했는데,
해당 글에서는 Optimistic Update를 적용하지 않은 코드를 보여주려고 한다.
왜냐하면 이 코드가 훨씬 간단하기 때문이다.
4️⃣ 북마크 삭제 기능 구현하기
북마크 삭제는 supabase의 `delete`를 이용하면 된다.
💻 deleteBookmark.js 코드
import { QUERY_KEY } from '../constants/queryKeys';
import supabase from '../supabase/client';
export const deleteBookMark = async ({ jobId, userId }) => {
try {
await supabase
.from(QUERY_KEY.BOOKMARKS)
.delete()
.eq('job_id', jobId)
.eq('user_id', userId);
} catch (error) {
console.error('북마크 취소 오류', error);
}
};
userId와 jobId를 인자로 받아서 bookmarks 테이블에서 해당 userId와 jobId에 맞는 데이터를 삭제한다.
💻 useDeleteBookmarkMutation.js 코드
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { deleteBookmark } from '../../api/bookmarks';
import { QUERY_KEY } from '../../constants/queryKeys';
export const useDeleteBookmarkMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteBookmark,
onSuccess: queryClient.invalidateQueries([QUERY_KEY.BOOMARKS]),
});
};
삭제 기능에도 useMutation 커스텀 훅을 만들어서 적용하였다.
5️⃣ 북마크 추가/삭제 기능 적용하기
이제 구현한 북마크 추가/삭제 기능을 컴포넌트에 적용해보자.
// 북마크 상태 확인 여부
const isBookmarked = job.bookmarks.some((bookmark) => bookmark.user_id === userId);
// UI
<button onClick={handleToggleBookMark}>
{isBookmarked ? (
<IoBookmark size={ICON_SIZE.BASE} className="text-my-main" />
) : (
<IoBookmarkOutline size={ICON_SIZE.BASE} />
)}
</button>;
먼저 사용자가 해당 채용 정보를 북마크했는지 안했는지 확인하는 로직이 필요했다.
jobs 테이블에서 가져온 북마크 정보에 사용자 id가 들어있다면 북마크했다는 것을 알 수 있다!
해당 값이 true이면 UI에서 북마크 표시된 상태를 보여주고, false라면 북마크가 표시되지 않은 상태를 보여준다.
Tanstack Query를 통해 Optimistic Update를 사용하고 있었기 때문에, useState로 북마크 상태를 관리할 필요가 없었다.
const { mutateAsync: createBookmark } = useCreateBookmarkMutation();
const { mutateAsync: deleteBookmark } = useDeleteBookmarkMutation();
앞에서 만들었던 useMutation 커스텀 훅들을 이곳에서 사용하면 된다.
`mutate`와 `mutateAsync`가 있는데, 이것은 밑에서 자세히 설명하겠다.
const handleToggleBookMark = (e) => {
e.preventDefault();
if (isBookmarked === false) {
createBookmark({ jobId, userId, jobs: job });
toast.success(BOOKMARK_MESSAGES.CREATE);
} else {
deleteBookmark({ jobId, userId });
toast.success(BOOKMARK_MESSAGES.DELETE);
}
};
북마크 버튼이 눌리지 않은 상태라면, 버튼을 눌렀을 때 supabase bookmarks 테이블에 정보가 추가된다.
반대의 상황이라면 bookmarks 테이블에서 정보가 삭제된다.
여기에서 useMutation을 사용할 때 각각의 코드들이 동기적으로 실행되어야 한다면, `mutate`를 사용하면 된다.
비동기적으로 실행해도 된다면 `mutateAsync`를 사용하면 된다.
전체 코드에서 일부를 가져와 순서대로 설명을 했는데, 이제 전체 코드를 보면서 전반적인 흐름을 보면 이해가 갈 것이다.
💻 JobItem.jsx 코드 - 북마크 추가/삭제를 사용하는 컴포넌트
import { IoBookmark, IoBookmarkOutline } from 'react-icons/io5';
import { Link } from 'react-router-dom';
import { toast } from 'react-toastify';
import { ICON_SIZE } from '../../constants/iconSize';
import { ROLE_MODE } from '../../constants/mode';
import { PATH } from '../../constants/routerPath';
import { BOOKMARK_MESSAGES } from '../../constants/toastMessages';
import { useCreateBookmarkMutation } from '../../hooks/bookmarks/useCreateBookmarkMutation';
import { useDeleteBookmarkMutation } from '../../hooks/bookmarks/useDeleteBookmarkMutation';
import separateDate from '../../utils/separateDate';
import sliceTitleLength from '../../utils/sliceTitleLength';
import useAuthStore from '../../zustand/useAuthStore';
/**
* 채용 정보를 보여주는 카드
* @param {object} job - props로 넘겨 받은 채용 정보
* @returns {JSX.Element}
*/
const JobItem = ({ job }) => {
const { id: jobId, company_name, recruit_title, start_date, end_date } = job;
const { user_id: userId, role } = useAuthStore((state) => state.user);
// 역할에 따라 북마크 버튼 보여지는 여부가 다름
const isSeeker = role === ROLE_MODE.SEEKER;
// 사용자가 해당 채용 정보를 북마크했는지 확인
const isBookmarked = job.bookmarks.some(
(bookmark) => bookmark.user_id === userId,
);
// useMutation 할당하기
const { mutateAsync: createBookmark } = useCreateBookmarkMutation();
const { mutateAsync: deleteBookmark } = useDeleteBookmarkMutation();
// 북마크 버튼 토글 함수
const handleToggleBookMark = (e) => {
e.preventDefault();
if (isBookmarked === false) {
createBookmark({ jobId, userId, jobs: job });
toast.success(BOOKMARK_MESSAGES.CREATE);
} else {
deleteBookmark({ jobId, userId });
toast.success(BOOKMARK_MESSAGES.DELETE);
}
};
// UI
return (
<Link to={`${PATH.JOB_DETAIL}/${jobId}`}>
<div
className={`mx-auto flex min-w-[600px] items-center justify-between gap-4 rounded-xl bg-white p-10 shadow-xl`}
>
{/** 채용 정보 */}
<div className="flex flex-col gap-2">
<h1 className="text-xl font-semibold">
{sliceTitleLength(company_name, 15)}
</h1>
<h2>{sliceTitleLength(recruit_title, 20)}</h2>
<div className="flex gap-4">
<span className="text-sm font-semibold">채용 날짜</span>
<span className="text-sm">
{`${separateDate(start_date)} ~ ${separateDate(end_date)}`}
</span>
</div>
</div>
<div className="flex items-center gap-3">
{/** 북마크 버튼 */}
{isSeeker && (
<button onClick={handleToggleBookMark}>
{isBookmarked ? (
<IoBookmark size={ICON_SIZE.BASE} className="text-my-main" />
) : (
<IoBookmarkOutline size={ICON_SIZE.BASE} />
)}
</button>
)}
{/** 지원한 자소서 */}
<div className="flex items-center gap-2 rounded-xl bg-my-main p-5">
<span>지원 자소서</span>
<span className="text-lg font-semibold">{`${job.resumes.length}건`}</span>
</div>
</div>
</div>
</Link>
);
};
export default JobItem;
느낀 점
북마크 추가/취소 기능을 만들면서 생각보다 우여곡절이 많았다.
북마크 추가/취소 함수 자체를 구현하는데 문제는 없었는데, UI에서 보여질 때 이것저것 신경써야할 것이 많았다.
마이페이지에서 북마크 취소를 했는데 채용 리스트에서는 새로고침을 해야 취소된 표시가 보여진다는 등등 사소한 오류들이 많이 발생하였다. 북마크 추가/취소를 하는 컴포넌트가 공통으로 쓰이다보니, 신경써야되는 부분이 많아서 그런 것 같다.
그래도 이번 프로젝트에서는 모든 오류들을 해결하고 마무리할 수 있어서 좋았다.
'Front-end > React' 카테고리의 다른 글
[React] React의 렌더링 과정 깊숙이 이해하기 (3) | 2025.06.02 |
---|---|
[React] useQuery에서 queryKey의 역할은 무엇일까? (0) | 2025.03.07 |
[MBTI 테스트] - Tanstack Query에서 Optimistic Updates 적용하기 (0) | 2025.02.25 |
[MBTI 테스트] - 카카오톡 공유하기 기능 만들기 (1) | 2025.02.24 |
[React] json-server란 무엇일까? (0) | 2025.02.24 |