본문 바로가기

FE

TanStack Query 낙관적 업데이트: onMutate + Rollback을 찜 해제에 적용

최근에 프론트엔드 사용자 경험 개선에 관해 공부를 하다가 낙관적 업데이트라는 개념을 알게 되었습니다.

 

이전에 진행했던 재고 판매 서비스에 한번 적용해 보면 좋을 거 같아서 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 — 요청 보내기 직전에 캐시 선반영

  1. cancelQueries로 진행 중인 refetch를 막아서
    낙관적 업데이트가 덮어써지는 상황을 예방합니다.
  2. getQueryData로 이전 캐시를 저장합니다. - 스냅샷
  3. setQueryData로 캐시에서 해당 아이템을 즉시 제거합니다. - 선반영
  4. 스냅샷을 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개입니다.

  1. cancelQueries : refetch가 낙관적 업데이트를 덮어쓰는 문제 방지
  2. previousWishlist 스냅샷 : 실패 시 되돌릴 근거 확보
  3. invalidateQueries : 최종적으로 서버 상태로 정합성 회복

 

9. 마무리 

낙관적 업데이트는 단순히 UI를 빠르게 만드는 기법이 아니라,  
캐시를 기준으로 UI를 설계하고 실패 시 롤백까지 함께 고려해야 하는 설계 문제에 가깝다고 느꼈습니다.  
이번 찜 해제 기능 적용을 통해 TanStack Query의 공식 패턴을 실제 프로젝트에서 경험해볼 수 있었고, 앞으로 더 유연하게 프로젝트에 적용해 볼 수 있을 것 같습니다.

 


 

 

 

 

 

updateshttps://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates