현재 하고 있는 프로젝트에서 좋아요 버튼을 구현하려고 한다.
🧩 요구사항
게임 목록 페이지에서 특정 게임 카드를 클릭하면 이러한 게임 상세 모달이 뜨는 구조이다.
API 응답받은 좋아요 여부에 따라 isLiked가 true면 채워진 좋아요 아이콘을, false면 비워진 좋아요 아이콘을 띄운다.

이런 식으로.. 좋아요 버튼 옆에는 좋아요 개수도 같이 표시한다.

🚀 옵티미스틱 업데이트(Optimistic Update) 방식
원래 그냥 구현하면 좋아요 버튼을 클릭하면 → 백엔드에 요청을 보내고 → 응답이 성공하면 업데이트하는 방식이다.
모르는 정보들을 불러오는 것도 아니고 그저 좋아요 on/off인데 응답이 올 때까지 기다려야 하면 사용자 입장에서 답답할 수 있다.
Optimistic Update 방식은 백엔드 응답을 기다리지 않고 먼저 UI를 업데이트하는 방식이다.
버튼을 눌렀을 때 바로 반응하도록 하여 사용자 경험을 매끄럽게 만든다.
✏️ 구현한 코드
use-toggle-like.mutation.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import customFetch from '@/shared/api/custom-fetch';
import type { SharedGameDetail } from '@/entities/shared-game/model/shared-game.type';
const likeSharedGame = async (sharedGameUuid: string) => {
return customFetch(`/shared-games/${sharedGameUuid}/like`, {
method: 'POST',
});
};
export const useToggleLike = (sharedGameUuid: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => likeSharedGame(sharedGameUuid),
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ['shared-game', sharedGameUuid] });
const prev = queryClient.getQueryData<SharedGameDetail>(['shared-game', sharedGameUuid]);
if (prev) {
const isLiked = prev.isLiked;
const newLikeCount = prev.likeCount + (isLiked ? -1 : 1);
queryClient.setQueryData<SharedGameDetail>(['shared-game', sharedGameUuid], {
...prev,
isLiked: !isLiked,
likeCount: newLikeCount,
});
}
return { prev };
},
onError: (err, _, context) => {
if (context?.prev) {
queryClient.setQueryData(['shared-game', sharedGameUuid], context.prev);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['shared-game', sharedGameUuid] });
},
});
};
API 요청 전에 이미 진행 중인 fetch가 캐시를 덮어쓰는 걸 방지한다.
캐시에서 이전 데이터를 불러와 isLiked, likeCount를 업데이트한다.
API를 요청하고 만약 요청이 실패한다면 캐시를 이전 상태로 롤백한다.
서버 데이터를 최신화한다.
| 단계 | 콜백 | 역할 |
| 1️⃣ onMutate | 요청 전에 실행 | 캐시를 즉시 수정 → 낙관적 UI 반영 |
| 2️⃣ mutationFn | 실제 요청 수행 | 좋아요 요청 API 호출 |
| 3️⃣ onError | 요청 실패 시 | 캐시를 이전 상태로 롤백 |
| 4️⃣ onSettled | 성공/실패 관계 없이 실행 | 서버 데이터 최신화 |
이 과정을 거치면 사용자가 좋아요 버튼을 누르자마자 좋아요 상태가 즉시 반영되고, 실패 시엔 이전 상태로 자동 복구된다.
like-button.tsx
'use client';
import { useRouter } from 'next/navigation';
import useAuthStore from '@/entities/auth/model/auth.store';
import ThumbsIcon from '@icons/thumbs-up.svg';
import ThumbsFilledIcon from '@icons/thumbs-up-fill.svg';
import { useToggleLike } from '../api/use-toggle-like.mutation';
interface LikeButtonProps {
sharedGameUuid: string;
isLiked?: boolean;
likeCount: number;
}
const LikeButton = ({ sharedGameUuid, isLiked = false, likeCount }: LikeButtonProps) => {
const isLoggedIn = useAuthStore((s) => s.isLoggedIn());
const router = useRouter();
const toggleLike = useToggleLike(sharedGameUuid);
const handleButtonClick = () => {
if (!isLoggedIn) {
router.push('/auth/login');
return;
}
toggleLike.mutate();
};
return (
<div className="flex items-center gap-2.5">
<button
type="button"
onClick={handleButtonClick}
aria-pressed={isLiked}
aria-label={isLiked ? '좋아요 취소' : '좋아요'}
className="size-6 cursor-pointer"
>
{isLiked ? <ThumbsFilledIcon /> : <ThumbsIcon />}
</button>
<span className="text-sm">{likeCount}</span>
</div>
);
};
export default LikeButton;
구현한 훅을 실제 버튼 컴포넌트에서 사용하자.
좋아요 버튼을 클릭하면 `toggleLike.mutate()`가 실행되면서 앞서 만든 로직이 작동한다.
네트워크 탭에서 인터넷 속도를 느리게 하고 좋아요 버튼을 눌러보니 네트워크 요청 전에 UI가 먼저 업데이트된다!
