요즘 리액트 생태계에서 react-query
, swr
같은 라이브러리를 활용해서 클라이언트-서버 상태 동기화, 상태관리를 하는 추세인 것 같다. npm trends만 봐도 그렇다.
나도 이 친구들을 회사에서도 개인적으로도 사용하고 있는데, 데이터의 optimistic update를 구현하는 과정에서 약간의 이슈가 발생했던 상황이 있었다. optimistic update가 뭔지에 대한 설명은 stack overflow의 답변으로 대체한다.. ㅎ
In an optimistic update the UI behaves as though a change was successfully completed before receiving confirmation from the server that it actually was - it is being optimistic that it will eventually get the confirmation rather than an error. This allows for a more responsive user experience.
더 나은 반응성 / 사용자 경험을 위한 것이고, 간단하게 설명하면 사용자의 액션에 의해 특정 서버 데이터의 변경이 발생했을때, 실제 서버로부터의 "성공했다"는 응답이 오기전에 성공할거라고 생각하고 성공했을때의 모습으로 데이터를 미리 업데이트 하는 것이다.
문제는 예상하지 못한 곳에서 발생했다. 관련 데이터가 모두 react-query
를 활용한 커스텀 훅으로 된 상황에서 일반적인 "좋아요" 같은 기능을 개발하는 중이였는데, 페이지를 들어오자마자 좋아요를 누른 경우에 optimistic update를 통해 곧바로 사용자에게 버튼의 모양을 변경시키는 방식으로 피드백을 주려고 했다.
하지만, 이상하게도 optimistic update를 통해서 버튼이 바뀌자마자 다시 이전의 상태로 돌아가는 문제가 발생했다. 서버 데이터는 변경이 되었는데, 사용자는 잘못된 데이터를 보고 있었던 것이다.
이 문제가 발생했던 이유는, optimistic update와 별개로 관련 데이터의 refetch가 일어났고 데이터 도착 타이밍이 묘하게 꼬였기 때문이였다. 해당 데이터는 서버사이드에서 미리 받아서 채워주고 있었고, react-query를 활용하게 되면 이 경우에 기본적으로 refetchOnMount
옵션이 true
로 설정되어 있어서 컴포넌트 mount와 동시에 데이터 최신성을 확신하기 위해 refetch
를 요청하게 된다. 그리고 이게 타이밍이 꼬이면 아래 그림과 같은 상황이 발생한다.
보여주고 싶은 데이터인 optimistic update 데이터는 잠깐만 보이고, 뒤늦게 도착한 refetch의 결과인 옛날 데이터가 마지막에 update 되어서 사용자는 (데이터가 서버사이드에서는 성공적으로 업데이트 되었다는 가정하에) 잘못된 옛날 데이터를 보게된다. 따라서 다시 refetch를 하기 전까지는, 계속해서 잘못된 데이터를 보고있게 된다.
import {
useQuery,
useMutation,
QueryClientProvider,
QueryClient,
useQueryClient
} from "@tanstack/react-query";
import _ from "lodash";
type User = {
id: number;
name: string;
age: number;
};
const QUERY_KEY = ["SAMPLE_QUERY_KEY"];
const pause = (delay: number) => {
return new Promise((r) => setTimeout(r, delay));
};
const queryClient = new QueryClient();
const InnerComponent = () => {
const queryClient = useQueryClient();
const { data: user, isLoading, refetch } = useQuery(QUERY_KEY, async () => {
console.log("START FETCH FUNC");
await pause(2000);
return {
id: 1,
name: "minsu",
age: 27
};
});
const onUpdate = useMutation<void, unknown, number>(
async () => {
return;
},
{
onMutate: (age) => {
queryClient.setQueryData<User>(QUERY_KEY, (prev) => {
if (!prev) {
return prev;
}
return { ...prev, age };
});
}
}
).mutate;
if (isLoading || !user) {
return <div>Loading...</div>;
}
const { id, name, age } = user;
return (
<div>
<h1>{name}</h1>
<ul>
<li>id: {id}</li>
<li>age: {age}</li>
</ul>
<button type="button" onClick={() => onUpdate(_.random(20, 80))}>
Change my age
</button>
<button type="button" onClick={() => refetch()}>
refetch
</button>
</div>
);
};
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<InnerComponent />
</QueryClientProvider>
);
}
위와 같은 코드로, optimistic update 하는 버튼 하나, refetch 버튼 하나를 배치한뒤, refetch는 오래걸리는 상황을 만들어서 테스트해본다. 그리고 그 결과는...
역시 refetch한 데이터가 살짝 늦게 오면서, optimistic update의 결과 데이터가 잠깐만 보였다가 다시 없어지게된다.
react-query
docs를 살펴보면, queryClient
의 메서드인 cancelQueries
를 통해서 특정 query key에 해당하는 데이터의 다른 업데이트를 무시하도록 할 수 있다.
The cancelQueries method can be used to cancel outgoing queries based on their query keys or any other functionally accessible property/state of the query.
This is most useful when performing optimistic updates since you will likely need to cancel any outgoing query refetches so they don't clobber your optimistic update when they resolve.
그래서 이걸 mutate function의 onMutate
콜백에 적용해주면, 아까와 동일한 테스트를 진행했을때에 optimistic update의 결과 데이터가 깜빡이지 않고 마지막에 보이게된다.
const onUpdate = useMutation<void, unknown, number>(
async () => {
return;
},
{
onMutate: (age) => {
queryClient.cancelQueries(QUERY_KEY);
queryClient.setQueryData<User>(QUERY_KEY, (prev) => {
if (!prev) {
return prev;
}
return { ...prev, age };
});
}
}
).mutate;
데이터의 정확성이 걱정된다면, onSuccess
, onError
, 또는 onSettled
콜백에서 invalidateQueries
호출을 통해 revalidate
하는 방법을 사용하면된다.
좋은거 하나 배운 것 같다. 좋은 사용자 경험을 위해서 optimistic update는 필수라고 생각하고 있는데, 데이터가 꼬일건 생각을 전혀 못했다. 앞으로는 잘 고려하면서 개발해야지.