사용자가 버튼을 클릭했을 때, 서버 응답을 기다리느라 반응이 느리면 사용자는 해당 기능이 오류가 발생했다고 생각을 할 수도 있습니다. 그래서 이번 게시글에서는 `TanStack Query`의 낙관적 업데이트(Optimistic Update)를 활용해 서버 응답을 기다리지 않고도 즉각적으로 UI가 반응할 수 있도록 구현한 내용을 공유해보려고 합니다.
구현 시나리오: 구인/구직 게시물 찜하기 ✨
- 사용자가 찜 버튼을 누르면 즉시 UI 변경.
- 서버 요청 실패 시 이전 상태로 롤백.
- 찜 상태는 게시물 상세 쿼리에 반영되어야 하며, 관련 쿼리 무효화.
UI 컴포넌트 📖
const RecruitDetailPage = () => {
const { favoriteMutate } = useFavoriteMutation();
const { data } = useGetJobQuery({ id: id as string });
// 해당 게시물의 찜 상태
const isFavorited: boolean = useMemo(() => data?.isFavorited, [data]);
// 찜하기 뮤테이션
const handleFavorite = () => {
const action = isFavorited ? 'delete' : 'post';
favoriteMutate({ action, jobId: job.id, jobPostType: job.postType });
};
return (
<NormalButton
onClick={handleFavorite}
...
>
<Icon name={isFavorited ? 'BookMarkOn' : 'BookMark'} />
</NormalButton>
...
);
}
찜 상태 확인
useMemo를 이용해 data가 변경될 때만 `isFavorited`를 재계산해 불필요한 리렌더링을 방지했습니다.
const { data } = useGetJobQuery({ id: id as string });
// 해당 게시물의 찜 상태
const isFavorited: boolean = useMemo(() => data?.isFavorited, [data]);
찜하기 핸들러 정의
`isFavorited` 값에 따라 `delete` 또는 `post` 값을 동적으로 결정해 낙관적 업데이트를 합니다.
const handleFavorite = () => {
const action = isFavorited ? 'delete' : 'post';
favoriteMutate({ action, jobId: job.id, jobPostType: job.postType });
};
UI 반영(찜 아이콘 변경)
이 UI 변경은 서버 응답과 상관없이 낙관적으로 반영된 캐시 상태를 기반으로 동작합니다.
return (
<NormalButton onClick={handleFavorite}>
<Icon name={isFavorited ? 'BookMarkOn' : 'BookMark'} />
</NormalButton>
);
useMutation을 이용해 구현한 낙관적 업데이트 📖
TanStack Query의 `useMutation`을 활용해 낙관적으로 업데이트하는 커스텀 훅입니다.
import { queryKeys } from '@src/services/queryKeys';
import { PostTypeEnum } from '@src/remotes/types/BaseSchema';
import { favoriteService } from '@src/services/favoriteService';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { FavoriteResponse } from '@src/remotes/types/FavoriteSchema';
interface FavoriteParams {
jobId: number;
action: 'post' | 'delete';
jobPostType: PostTypeEnum;
}
// 찜하기 뮤테이션 (낙관적 업데이트)
export const useFavoriteMutation = () => {
const queryClient = useQueryClient();
const { mutate: favoriteMutate } = useMutation<
FavoriteResponse, // 응답 데이터 타입
Error, // 에러 타입
FavoriteParams, // mutationFn 파라미터 타입
{ previousFavorites: any; jobId: number; jobPostType: PostTypeEnum } // 컨텍스트 타입
>({
onError: (err, variables, context) => {
// 에러 발생 시 이전 상태로 롤백
if (context?.previousFavorites) {
queryClient.setQueryData(queryKeys.job.detail(context.jobId.toString()), context.previousFavorites);
}
},
mutationFn: async ({ jobId, action, jobPostType }: FavoriteParams) => {
if (action === 'post') {
return await favoriteService.favorite.postFavorite(jobId, jobPostType); // 찜하기
} else {
return await favoriteService.favorite.deleteFavorite(jobId); // 찜 해제
}
},
onSettled: (data, error, variables, context) => {
const jobId = context?.jobId;
const jobPostType = context?.jobPostType;
if (jobId && jobPostType) {
// 요청이 완료되면 상세 페이지, 찜 목록 쿼리 무효화
queryClient.invalidateQueries({ queryKey: queryKeys.job.detail(jobId.toString()) });
queryClient.invalidateQueries({
queryKey: queryKeys.job.favoriteList(jobPostType)
});
}
},
onMutate: async ({ jobId, action, jobPostType }) => {
// 진행 중인 쿼리 취소
await queryClient.cancelQueries({ queryKey: queryKeys.job.detail(jobId.toString()) });
// 이전 데이터 저장
const previousFavorites = queryClient.getQueryData(queryKeys.job.detail(jobId.toString()));
// UI를 즉시 업데이트
queryClient.setQueryData(queryKeys.job.detail(jobId.toString()), (old: any) => {
return {
...old,
isFavorited: action === 'post' // 찜하기 상태 업데이트
};
});
return { jobId, jobPostType, previousFavorites }; // 이전 상태를 반환
}
});
return { favoriteMutate };
};
onMutate
낙관적 업데이트의 핵심 이라고 할 수 있는 코드입니다.
onMutate: async ({ jobId, action, jobPostType }) => {
// 진행 중인 쿼리 취소
await queryClient.cancelQueries({ queryKey: queryKeys.job.detail(jobId.toString()) });
// 이전 데이터 저장
const previousFavorites = queryClient.getQueryData(queryKeys.job.detail(jobId.toString()));
// UI를 즉시 업데이트
queryClient.setQueryData(queryKeys.job.detail(jobId.toString()), (old: any) => {
return {
...old,
isFavorited: action === 'post' // 찜하기 상태 업데이트
};
});
return { jobId, jobPostType, previousFavorites }; // 이전 상태를 반환
}
✨ 동작 순서
1. 백그라운드에서 진행 중인 쿼리를 취소합니다.
2. 실패했을 때 롤백할 수 있도록 현재 캐시 상태를 저장합니다.
3. 사용자가 버튼을 누르자마자 `isFavorited`값을 변경해 UI를 즉시 업데이트 합니다.
4. 에러나 완료 시 활용할 수 있도록 이전 상태를 콘텍스트로 반환합니다.
mutationFn
사용자가 선택한 액션(post, delete)에 따라 실제 서버에 요청을 보냅니다.
mutationFn: async ({ jobId, action, jobPostType }: FavoriteParams) => {
if (action === 'post') {
return await favoriteService.favorite.postFavorite(jobId, jobPostType); // 찜하기
} else {
return await favoriteService.favorite.deleteFavorite(jobId); // 찜 해제
}
}
onError
서버 요청이 실패했을 경우 `onMutate`에서 백업해둔 이전 상태로 롤백합니다.
onError: (err, variables, context) => {
// 에러 발생 시 이전 상태로 롤백
if (context?.previousFavorites) {
queryClient.setQueryData(queryKeys.job.detail(context.jobId.toString()), context.previousFavorites);
}
}
onSettled
서버의 최신 상태를 반영하기 위해 관련 캐시를 무효화합니다. (상세 페이지, 찜 목록)
onSettled: (data, error, variables, context) => {
const jobId = context?.jobId;
const jobPostType = context?.jobPostType;
if (jobId && jobPostType) {
// 요청이 완료되면 상세 페이지, 찜 목록 쿼리 무효화
queryClient.invalidateQueries({ queryKey: queryKeys.job.detail(jobId.toString()) });
queryClient.invalidateQueries({
queryKey: queryKeys.job.favoriteList(jobPostType)
});
}
}
예시 화면 📖
어려웠던 점 ❓
낙관적 업데이트 흐름
onMutate -> onError -> onSettled의 흐름을 이해하는데 시간이 걸렸고
찜 여부가 포함된 상세 쿼리를 기반으로 동작하다 보니, queryKey 설계와 캐시 업데이트 범위를 명확히 해야 했습니다.
회고 🧐
낙관적 업데이트는 단순히 UI를 빠르게 바꾸는 기술이 아니라, 사용자 경험을 향상하는 UX 전략이라고 생각합니다.
그래서 성능 뿐만 아니라 사용자 경험을 개선할 수 있었던 새로운 도전이어서 이번 기능 구현은 좋은 경험이 된 것 같습니다.

아직 많이 부족하기 때문에 조언은 언제나 환영입니다 :)
출처 🏷️
https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates
'공부 > 상태 관리' 카테고리의 다른 글
전역 상태 관리, 정말 필요한가? (0) | 2024.12.10 |
---|---|
Zustand 동작 원리 알아보기 (1) | 2024.09.06 |
Zustand 알아보기 (0) | 2024.09.06 |
TanStack Query 알아보기 3 (Query Cancellation, Optimistic Updates, Prefetching, Paginated, Infinite Queries) (0) | 2024.09.06 |
TanStack Query 알아보기 2 (동작 원리) (0) | 2024.09.06 |
TanStack Query 알아보기 1 (기본 사용법) (0) | 2024.09.06 |