최근에 프론트엔드 사용자 경험 개선에 관해 공부를 하다가 낙관적 업데이트라는 개념을 알게 되었습니다.
이전에 진행했던 재고 판매 서비스에 한번 적용해 보면 좋을 거 같아서 TanStack Query를 사용하여 적용해보고자 합니다.
1. 낙관적 업데이트란?
| Optimistic Update(낙관적 업데이트)
낙관적 업데이트는 mutation 이 성공적으로 반영될 것이라 낙관적으로 판단을 하고
서버로 부터 응답이 오기 전에 UI 업데이트를 먼저 하는 것을 말합니다.
2. 왜 필요할까
일반적인 흐름은:
- 버튼 클릭
- 서버 요청
- 응답 기다림
- 성공 후 UI 변화
이 구조는 네트워크가 느리거나 서버가 바쁠 때 사용자 입장에서는 눌렀는데 반응이 없는 느낌이 생기고, 이것이 UX를 나쁘게 만듭니다.
낙관적 업데이트는 이 문제를 해결하려고:
- 버튼 클릭
- 즉시 UI 변화
- 뒤에서 서버 요청 처리
- 실패하면 복구 / 성공하면 유지
이 흐름으로 바꿉니다.
->사용자가 서버의 응답을 기다리지 않고 지연 없이 즉시 피드백받게 되므로, 사용자 경험 개선에 큰 도움이 될 수 있습니다.
다만 오류가 발생했을 때 이를 적절히 처리하고 원상 복구 시키는 로직을 잘 작성해야 하고, 금전적인 정보, 사용자 개인 정보 등 민감한 정보에는 사용에 주의하여야 합니다.
3. 공식문서가 말하는 두 가지 방식
TanStack Query 문서에서 낙관적 업데이트는 크게 2가지로 소개됩니다.
(1) UI 방식
- 캐시는 안 건드리고
- useMutation의 isPending, variables 같은 상태로
- 화면에 “임시 항목”을 렌더링
장점: 단순, 롤백 로직 거의 필요 없음
단점: 같은 데이터가 여러 화면에서 공유되면 반영이 제한적
(2) Cache 방식 - 실습한 방식
- onMutate로 캐시를 먼저 수정
- 실패하면 onError에서 스냅샷으로 롤백
- 항상 onSettled에서 invalidateQueries로 최종 동기화
장점: 캐시가 바뀌므로 화면 여러 곳에 즉시 반영됨
단점: 롤백/동시성까지 고려해야 해서 더 신중해야 함
4. 어떤 기능에 적용했나?
프로젝트에서 상품목록에서 찜을 누르거나 해제할 때
낙관적 업데이트가 적용되도록 해보겠습니다.
찜 목록 페이지의 찜 해제를 골라서 진짜 낙관적 업데이트(onMutate+rollback)를 적용했습니다

.
5. 구조 변경 1단계: 조회를 useQuery로 캐싱
기존에는 useEffect로 직접 API 호출하고 useState에 담는 방식이었습니다.
하지만 낙관적 업데이트에서 중요한 포인트는:
UI는 “캐시 데이터”를 기반으로 렌더링해야, 캐시를 선반영했을 때 UI도 즉시 따라옵니다.
그래서 찜 목록 조회를 useQuery({ queryKey: ['wishlist'], queryFn: getWishlistProducts })로 바꿔서
찜 목록을 React Query 캐시에 올려두는 구조로 만들었습니다.
2. React Query로 조회 + 캐시 먼저 만들기
(1) 기존 코드
- useEffect 안에서 getWishlistProducts()를 직접 호출해서 wishListItem 로컬 state를 채우는 방식
useEffect(() => {
const fetchWishlistItems = async () => {
setLoading(true);
setError(null);
const wishlistData = await getWishlistProducts();
const mappedWishlistItems = wishlistData.map(...);
setWishListItems(mappedWishlistItems);
...
};
fetchWishlistItems();
}, []);
(2) 변경 후 – useQuery 사용
- React Query의 useQuery로 찜 목록을 ["wishlist"] 키로 캐시에 올리고, select에서 바로 카드 렌더링에 필요한 필드만 파생시킴.→ 캐시만 바뀌면 즉시 UI가 재렌더링되므로 낙관적 업데이트 흐름이 더 명확함.
const { data: wishListItems = [], isLoading, isError } = useQuery({
queryKey: ["wishlist"],
queryFn: getWishlistProducts,
select: (wishlistData = []) =>
wishlistData.map((item) => ({
id: item.id,
storeName: item.store_name || item.store?.name || "상점",
productName: item.name,
category: item.category_name || item.category?.name || "카테고리",
imageUrl: item.image,
originalPrice: item.price,
discountPrice: item.discount_price,
isLiked: true,
})),
onError: () => setError("찜 목록을 불러오는데 실패했습니다."),
});
//로딩/에러 표시도 react query에서 주는 isloading,isError 를 그대로 사용
6. 구조 변경 2단계: 찜 해제에 onMutate + rollback 적용
Step A) onMutate — 요청 보내기 직전에 캐시 선반영
- cancelQueries로 진행 중인 refetch를 막아서
낙관적 업데이트가 덮어써지는 상황을 예방합니다. - getQueryData로 이전 캐시를 저장합니다. - 스냅샷
- setQueryData로 캐시에서 해당 아이템을 즉시 제거합니다. - 선반영
- 스냅샷을 return 해서 롤백용 context를 남깁니다.
→ 결과: 사용자는 클릭하자마자 카드가 사라져서 “즉시 반응”을 체감합니다.
Step B) onError — 실패 시 스냅샷으로 롤백
- 서버 요청이 실패하면,
- context.previousWishlist를 다시 setQueryData로 넣어서 원래 상태로 되돌립니다.
→ 결과: 실패하면 카드가 다시 나타나고, 사용자는 실패를 인지합니다.
Step C) onSettled — 항상 최종 동기화
성공이든 실패든 마지막에는:
- invalidateQueries(['wishlist'])
로 서버의 “진짜 정답 상태”로 다시 맞춥니다.
→ 결과: UI가 빠르게 반응하면서도, 최종적으로 서버 상태가 유지됩니다.
3. 찜 해제에 TanStack Query 낙관적 업데이트 적용
(1) 이전 코드
const handleLikeToggle = async (productId, isLiked) => {
await toggleWishlist(productId, !isLiked);
if (!isLiked) {
// 새 상태가 false일 때(=해제) 로컬 state에서 제거
setWishListItems((prev) => prev.filter((item) => item.id !== productId));
}
};
- 서버 응답을 기다렸다가, 성공하면 그때 로컬 state에서 카드 제거.
- 실패했을 때 롤백은 없음(그냥 콘솔만 찍음).
- 서버 응답을 받은 뒤에 로컬 state를 수정, 실패 시 롤백 불가
(2) 변경 후 – useMutation + onMutate + rollback
const toggleWishlistMutation = useMutation({
mutationFn: ({ productId, newLiked }) => toggleWishlist(productId, newLiked),
// 1) onMutate: 요청 보내기 직전에 캐시를 낙관적으로 수정
onMutate: async ({ productId, newLiked }) => {
await queryClient.cancelQueries({ queryKey: ["wishlist"] });
const previousWishlist = queryClient.getQueryData(["wishlist"]);
// 위시리스트 화면에서는 주로 "해제"가 주 동작이므로, 낙관적으로 캐시에서 제거
if (!newLiked) {
queryClient.setQueryData(["wishlist"], (old = []) =>
old.filter((item) => item.id !== productId)
);
}
// rollback용 스냅샷 반환
return { previousWishlist };
},
// 2) onError: 실패 시 스냅샷으로 롤백
onError: (_err, _variables, context) => {
if (context?.previousWishlist) {
queryClient.setQueryData(["wishlist"], context.previousWishlist);
}
alert("찜 상태 변경에 실패했습니다. 잠시 후 다시 시도해주세요.");
},
// 3) onSettled: 성공/실패 상관없이 최종 동기화
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["wishlist"] });
},
});
//카드 컴포넌트에서 전달한 isLiked를 반전시켜 mutation 실행 ->>클릭 즉시카드가 사라지고,실패시자동복구
const handleLikeToggle = (productId, isLiked) => {
// API에는 기존 로직처럼 반대 값(!isLiked)을 맞춰서 전달
toggleWishlistMutation.mutate({ productId, newLiked: !isLiked });
};
- onMutate: 요청 직전에 캐시에서 해당 아이템을 먼저 제거(낙관적 업데이트), 동시에 이전 캐시를 스냅샷으로 반환.
- onError: 실패하면 스냅샷을 그대로 되돌려 UI를 롤백하고 사용자에게 안내.
- onSettled: 성공/실패와 관계없이 invalidate로 서버 상태와 최종 동기화.
- 이렇게 캐시만 수정해도 select를 통해 변환된 wishListItems가 바로 재계산되어 UI가 즉시 반응한다.
7. UX 관점에서 뭐가 달라졌나
- 클릭 직후
- 예전: 서버 응답 올 때까지 UI 변화 없음 → 느리게 느껴질 수 있음.
- 지금: onMutate에서 캐시에서 해당 아이템을 바로 제거 →카드가 즉시 화면에서 사라져서 바로 반응하는 느낌
- 서버 성공
- 예전: 성공을 기다린 뒤 setWishListItems로 제거 → UX는 비슷하지만 반응이 약간 늦음.
- 지금: 이미 낙관적으로 캐시에서 제거된 상태라, invalidateQueries 후에도 서버가 정말 성공했다면 리스트 상태는 그대로 유지.
- 서버 실패
- 예전: 실패해도 카드가 이미 사라진 상태라면 복구가 안 됐을 것(현재 코드에서는 실패 시 UI 롤백 없음).
- 지금: onError에서 previousWishlist 스냅샷으로 캐시를 원래대로 복구 →리스트에도 다시 카드가 나타나고, alert로 사용자에게 실패 안내.
→ 이게 바로 TanStack Query 공식 문서에서 말하는
“onMutate로 캐시 선반영 → onError에서 롤백 → onSettled에서 invalidate” 패턴
8. 동시성 / 안정성 관점
이 패턴에서 안정성을 보장하는 장치는 크게 3개입니다.
- cancelQueries : refetch가 낙관적 업데이트를 덮어쓰는 문제 방지
- previousWishlist 스냅샷 : 실패 시 되돌릴 근거 확보
- invalidateQueries : 최종적으로 서버 상태로 정합성 회복
9. 마무리
낙관적 업데이트는 단순히 UI를 빠르게 만드는 기법이 아니라,
캐시를 기준으로 UI를 설계하고 실패 시 롤백까지 함께 고려해야 하는 설계 문제에 가깝다고 느꼈습니다.
이번 찜 해제 기능 적용을 통해 TanStack Query의 공식 패턴을 실제 프로젝트에서 경험해볼 수 있었고, 앞으로 더 유연하게 프로젝트에 적용해 볼 수 있을 것 같습니다.
updateshttps://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates
'FE' 카테고리의 다른 글
| 건강검진 결과 시각화 : 차트 라이브러리 선정 및 구현 전략 (0) | 2026.01.01 |
|---|---|
| PDF 업로드가 느린 이유와 런칭을 위한 성능 최적화 (0) | 2025.12.30 |
| [멋사] 2025 4호선톤 회고 (0) | 2025.11.17 |
| 전역 상태 라이브러리 (1) | 2025.11.17 |
| [Javascript] 웹 애니메이션 , requestAnimationFrame (0) | 2025.11.06 |