React 서비스에서 서버상태(Server State)를 관리하기 위해서 Tanstack Query(React Query)는 여러모로 유용한 툴이다. 이 때, 단순 캐싱이나 API 상태 관리를 넘어서 UI와 접목해서 활용할 만한 개념이 있다. 바로 invalidateQueries와 Optimistic Updates다. 실제 실무에서 다양하게 적용해보지는 못했지만 추후 보다 나은 활용을 위해 이 두 가지 개념을 파악해보고자 한다.
웹서비스는 더 나은 UX를 위해 다음 두 가지를 동시에 만족해야 한다.
데이터의 일관성
→ 서버 데이터와 UI가 가능한 동일한 상태를 유지해야 한다.
빠른 UI 반응
→ 사용자는 입력에 따라 즉시 화면이 바뀌길 원한다.
특히 이커머스 플랫폼 등 사용자의 인터렉션이 다양한 서비스에서는 장바구니 수량 변경, 좋아요, 찜하기 등 사용자의 입력에 따라 빠르게 UI가 변해야하는 동시에 서버에 데이터를 넘겨야 하는 상황이 많다.
React Query는 이를 위해 다음 두 가지 기능을 제공한다.
invalidateQueries Optimistic Updates// Tanstack Query > Query Invalidation
// 캐시에 있는 모든 쿼리를 invalidate
queryClient.invalidateQueries()
// 'todos'로 시작하는 키를 가진 모든 쿼리를 invalidate
queryClient.invalidateQueries({ queryKey: ['todos'] })
invalidateQueries()는 React Query 쿼리를 “stale” 상태로 만드는 API이다.
이 상태는 useQuery나 관련 훅에서 사용된 staleTime 설정을 'stale'상태로 덮어씌운다. 만약 쿼리가 실행 중이라면 백그라운드에서 refetch된다. 즉, 강제로 새로운 데이터를 가져오는 조건이 되도록 만드는 것이다.
다음과 같이 invalidate 될 쿼리 키를 상세하게 지정할 수도 있다.
// 📌 특정 쿼리 키만 invalidate 하기
queryClient.invalidateQueries({
queryKey: ['todos', { type: 'done' }],
})
// ex) invalidate ✅
const todoListQuery = useQuery({
queryKey: ['todos', { type: 'done' }],
queryFn: fetchTodoList,
})
// ex) invalidate ❌
const todoListQuery = useQuery({
queryKey: ['todos'],
queryFn: fetchTodoList,
})
// 📌 정확히 'todos' 라는 쿼리키를 가진 쿼리만 invalidate
queryClient.invalidateQueries({
queryKey: ['todos'],
exact: true,
})
// 📌 조건에 맞는 쿼리키만 invalidate
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' && query.queryKey[1]?.version >= 10,
})
// ex) invalidate ✅
const todoListQuery = useQuery({
queryKey: ['todos', { version: 10 }],
queryFn: fetchTodoList,
})
// ex) invalidate ❌
const todoListQuery = useQuery({
queryKey: ['todos', { version: 5 }],
queryFn: fetchTodoList,
})
mutate 이후 invalidateQueries는 다음과 같이 적용한다.
// Tanstack Query > Invalidations from Mutations
import { useMutation, useQueryClient } from '@tanstack/react-query'
const queryClient = useQueryClient()
// 이 mutation 요청이 성공한 이후, 'todos'나 'reminders'등의 쿼리 키와 관련된 쿼리를 invalidate
const mutation = useMutation({
mutationFn: addTodo,
onSuccess: async () => {
// 하나의 쿼리를 invalidate 할 경우,
await queryClient.invalidateQueries({ queryKey: ['todos'] })
// 여러 개의 쿼리를 invalidate 할 경우,
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['todos'] }),
queryClient.invalidateQueries({ queryKey: ['reminders'] }),
])
},
})
Optimistic Update는 서버 응답을 기다리지 않고 UI를 먼저 변경하는 기법이다.
단, 서버 요청이 실패하는 케이스를 대비하기 위해 기존 상태를 백업해뒀다가 필요하면 롤백하는 구조를 사용한다.
invalidateQueries로 최신화// Tanstack Query > Optimistic Updates(Via the cache)
// Todo 리스트 예제: 새로운 todo를 등록한 이후 todos 리스트를 업데이트하기
const queryClient = useQueryClient()
useMutation({
mutationFn: updateTodo,
// mutate가 호출되었을 때:
onMutate: async (newTodo, context) => {
// 기존 refetch 요청 취소(Optimistic Update를 덮어쓰지 않도록)
await context.client.cancelQueries({ queryKey: ['todos'] })
// 이전 값 스냅샷 저장
const previousTodos = context.client.getQueryData(['todos'])
// Optimistic Update(낙관적 업데이트)
context.client.setQueryData(['todos'], (old) => [...old, newTodo])
// 스냅샷 return
return { previousTodos }
},
// Mutation 실패 시 onMutate에서 return한 결과로 롤백
onError: (err, newTodo, onMutateResult, context) => {
context.client.setQueryData(['todos'], onMutateResult.previousTodos)
},
// 성공/실패 상관없이 refetch
onSettled: (data, error, variables, onMutateResult, context) =>
context.client.invalidateQueries({ queryKey: ['todos'] }),
})
UI를 직접 업데이트 하는 방식도 있지만, 화면의 여러 곳에서 동시에 변경 내용을 반영해야 한다면 캐시를 조작하여 자동으로 반영되게 하는 것이 더 좋다.
이커머스 서비스 내의 '좋아요' 기능은 다음과 같은 특성이 있다.
이러한 UI와 데이터의 문제는 다음과 같은 조합으로 해결할 수 있다.
Optimistic Update로 즉시 반응하는 UI 구현invalidateQueries로 서버 데이터와 동기화const mutation = useMutation(toggleLike, {
onMutate: async (productId) => {
// 1. 기존 요청 중단
await queryClient.cancelQueries(["product", productId]);
// 2. 이전 상태 백업
const previousDetail = queryClient.getQueryData(["product", productId]);
const previousList = queryClient.getQueryData(["products", "list"]);
// 3. 상품상세 페이지 캐시 Optimistic Update
queryClient.setQueryData(["product", productId], (old) => ({
...old,
isLiked: true,
likeCount: old.likeCount + 1,
}));
// 4. 상품목록 페이지 캐시 Optimistic Update
queryClient.setQueryData(["products", "list"], (old) => {
if (!old) return old;
return old.map((prod) =>
prod.id === productId
? { ...prod, isLiked: true, likeCount: prod.likeCount + 1 }
: prod
);
});
return { previousDetail, previousList };
},
// 실패 시 롤백
onError: (err, productId, context) => {
queryClient.setQueryData(["product", productId], context.previousDetail);
queryClient.setQueryData(["products", "list"], context.previousList);
},
// 성공/실패 상관없이 최신 데이터 refetch 세팅
onSettled: (productId) => {
queryClient.invalidateQueries(["product", productId]);
queryClient.invalidateQueries(["products", "list"]);
},
});
이와 같이, invalidateQueries와 Optimistic Updates의 조합을 활용하여 사용자의 UX를 개선할 수 있다.