이전 글에서는 사용자에게 Loading을 시각화하여 보여주는 케이스에 대해 다뤘는데, 사실 대부분의 경우 loading은 제거할 수 있다면 제거하는 게 무조건 좋다는 것에 이견이 없을 것이다. 그래서 이번에는 반대로 로딩을 어떻게 최소화할 수 있는지에 대해서 얘기해보려고 한다.
대부분의 loading state handling case는 api 응답이 오기 전까지 화면에 그려줄 data가 없기 때문에 발생한다. 즉, api 응답이 오기전에 화면에 보여줄 data를 미리 갖고 있기만 하면 사용자에게 로딩을 보여줄 필요 자체가 성립하지 않는다. 이런 관점에서 일부 api response를 local storage에 저장한 뒤, 이후 api 응답이 오기전까지 일종의 default value로써 재사용하여 로딩을 노출하지 않는 전략을 취할 수 있다.
다짐에서는 대표적으로 home api response를 local storage에 저장하고 있다. 적어도 사용자들이 앱을 실행했을 때 가장 먼저 보게되는 홈에서만큼은 로딩을 경험하지 않았으면 좋겠다는 바람때문이다. 사용자가 앱을 실행하면 먼저 local storage에 저장된 data를 꺼내와 홈을 렌더링하고, 이후 response가 도착하면 최신 데이터를 local storage와 home에 반영해준다. 이런 플로우 덕분에 앱 최초 실행 이후에는 로딩 없이 홈 컨텐츠에 접근할 수 있게 되었다.
참고로 default value가 셋팅되어 있음에도 히어로배너에 skeleton이 보이는 이유는, 이미지를 로드하는데 얼마간의 시간이 소요되기 때문이다. local storage에 저장하고 있는 데이터는 imageUrl이기 때문에, 앱 실행시 이미지를 다운받는 시간이 필요하고, 그동안 빈 박스가 노출된다. 사용자 경험 측면에서 흰색 배경이 노출되는 것보다는 skeleton을 표기하는게 더 낫지 않을까 싶어 이미지가 로드될 때까지 skeleton을 노출해준 것이다.
다짐은 api 라이브러리로 react query를 사용하고 있는데, react query의 핵심 기능 중 하나가 바로 api response 자동 캐싱이다. 즉, 최초 요청 이후 다시 동일한 query key로 api 요청이 발생했을 때 즉시 cache의 data를 반환해주므로 loading을 노출할 필요가 없다. 조금 더 세부적으로 들어가보면, react query에는 cache time과 stale time이 있는데, cache time은 data가 cache로 저장되어 있는 시간, stale time은 data가 유효한 시간을 나타낸다. 만약 stale time이 경과되었고, cache time은 여전히 유효하다면 react query는 stale한 data를 우선적으로 반환하고 동시에 network fetch를 통해 최신 data를 받아와 비동기적으로 할당해준다. 이런 플로우는 데이터를 즉시 반환하는 동시에 주기적으로 최신화까지도 도모할 수 있기 때문에 굉장히 유용하다.
서버 데이터를 변경하는 mutation을 호출한 이후에는 query data를 최신화해서 변경사항을 즉시 뷰에 반영해주는 것이 일반적이다. 그리고 보통 데이터 최신화를 위해 사용하는 것이 바로refetch다. refetch는 현재 캐싱된 데이터를 반환하지 않고 무조건 network fetch를 발생시키므로 최신 데이터임을 보장한다. 하지만 data를 refetch한다는 것은 곧 데이터 응답까지 지연이 발생한다는 뜻이다. 이때 refetch 없이 최신 데이터를 뷰에 반영하는 방법이 있다. 바로 클라이언트에서 직접 저장된 cache를 수정해주는 것이다.
const queryClient = useQueryClient();
const toggleLikeCache = ({gymId,isLiked}:{gymId:string; isLiked:boolean;}) =>{
const detailQueryResponse =
queryClient.getQueryData<FetchGymResponse([
QueryKeys.gym,
gymId,
]); // 현재 저장되어 있는 cache를 가져온다.
if (detailQueryResponse) {
queryClient.setQueryData<FetchGymResponse>(
[QueryKeys.gym, gymId], //cache를 가져왔던 것과 동일한 querykey에 새로운 cache를 넣어준다.
(oldData?: FetchGymResponse) => {
if (!oldData) {
return {
...detailQueryResponse,
};
}
return {
...oldData,
result: {
center: {
...oldData.result.center,
isLiked: !isLiked, //마음대로 data를 custom해서 넣어줄 수 있다.
},
},
};
},
);
}
}
위 코드는 다짐에서 운동시설 상세페이지의 '찜' 버튼을 눌렀을 때 실행되는 cache 수정 코드 일부를 가져온 것이다. 본래 찜 mutation이 성공했다면, 서버 데이터가 변경됐다는 뜻이기 때문에 운동시설 상세페이지 쿼리를 refetch하여 데이터를 최신화해주어야한다. 하지만 이는 얼핏 비효율적으로 보인다. 굳이 api를 다시 요청할 필요도 없이 like mutation의 response가 성공했는지, 실패했는지 여부만 알면, 그 결과로 서버 데이터가 어떻게 바뀌었는지 클라이언트 측에서 충분히 예측 가능하기 때문이다. 따라서 다짐에서는 찜 mutation 이후 refetch 없이 직접 api cache를 수정하여 뷰에 반영해주는 방법을 채택했다. 직접 캐시를 수정하면 서버 부하 감소는 물론, refetch phase가 생략 가능하므로, 사용자는 mutation 응답이 오는 즉시 피드백을 받아볼 수 있게되어 사용성 또한 개선된다. 여기서 한발자국 더 나아가서 mutation response가 오기전에 mutation이 성공했다고 가정하고 cache를 우선 수정하는 optimistic upate까지 적용한다면 사용성을 극한으로 개선할 수도 있다.
react query에서 캐시를 수정하는 코드는 충분히 재사용성이 높다는 생각이 들어서 이참에 cache updater를 만들어서 코드를 리팩토링하기로 했다.
import {QueryClient, QueryKey} from 'react-query';
export const cacheUpdater = <Response>({
client: queryClient,
queryKey,
updater,
}: {
client: QueryClient;
queryKey: QueryKey;
updater: (oldData: Response) => Response;
}) => {
const detailQueryResponse = queryClient.getQueryData<Response>(queryKey);
if (!detailQueryResponse) {
return;
}
queryClient.setQueryData<Response>(
queryKey,
(oldData: undefined | Response) =>
oldData
? updater(oldData)
: detailQueryResponse,
);
};
const updater = (oldData: FetchGymResponse) => ({
...oldData,
result: {
center: {
...oldData.result.center,
isLiked: !isLiked,
},
},
});
cacheUpdater<FetchGymResponse>({
client: queryClient,
queryKey: [QueryKeys.gym, gymId],
updater,
});