[포즈를 부탁해] Recoil 비동기 데이터 Refetch 방법에 대한 고찰

이번 포스팅에서는 작년부터 조인하여 개발하고 운영중인 인생네컷 포즈추천 서비스 <포즈를 부탁해>에서 Recoil로 서버 데이터를 받아오면서 캐싱 데이터 때문에 상태 불일치가 발생했던 경험과, 이 문제를 해결하기 위해 시도했던 Refetch 방안들에 대해서 공유해봅니다.
배경
포즈를 부탁해는 23년 3월, 열흘간 진행되는 해커톤인 포텐데이를 통해서 시작된 프로젝트입니다. (참고로 저는 포텐데이가 끝나고 나서 서비스를 확장하는 과정에서 추가로 영입되었습니다.) 프로젝트의 첫 시작에는 개발자가 프론트엔드 개발자 한명이었는데요, 이후 서비스 고도화와 함께 백엔드 개발자 분이 들어오시고 서버도 생겼습니다. 따라서 개발초기에는 서버 연동 데이터를 받아올 필요가 없었고, 상태관리 라이브러리는 전역상태관리를 위한 Recoil 하나만 사용하고 있었습니다.
최초의 포즈 찜하기 기능은 서버없이 Local Storage를 이용해서 구현되어있었습니다. 그리고 추후 서버에 포즈 찜하기 관련 API가 추가되었고, Local Storage 기반의 찜하기 기능을 서버 데이터 기반으로 변경하는 작업을 제가 맡아서 진행하게 되었습니다.
마주한 문제
기존의 <포즈를 부탁해>는 별도로 서버 상태 관리를 위한 데이터 fetching 라이브러리 (ex. React-Query, SWR 등)는 사용하지 않고 진행하고 있었습니다. Recoil에서도 Selector를 활용해 비동기 데이터를 가져오는 것이 가능했고, 서버에서 불러온 데이터를 사용할 때 매번 요청을 보내지 않고도 저장해 둔 데이터를 재사용할 수 있기 때문에 Recoil 만으로도 충분했습니다. 또한 Suspense, Error Boundary 와 같은 편리한 기능이 있어 비동기 데이터를 처리하기에도 적합하다고 생각했습니다. 저희는 이렇게 Recoil을 이용해 서버에 저장된 포즈 데이터를 문제없이 받아와 제공하고 있었습니다.
하지만 데이터를 받아와 보여주기만 하는 기존 기능과 달리, 요청을 보내서 서버 상태를 수정하고 다시 화면에 렌더링하는 포즈 찜하기 같은 기능에서 예상치 못한 문제를 마주하게 됩니다.

찜하기 버튼은 포즈 리스트를 볼 수 있는 화면에 위치하고 있어 사용자는 여기서 마음에 드는 포즈를 선택할 수 있습니다. 그러고 나서 사용자가 마이페이지의 찜한 포즈 탭에 가면 자신이 좋아요 누를 포즈들을 볼 수 있습니다.
바로 여기서 컴포넌트간 상태 불일치 문제가 발생했습니다. 포즈 리스트에서 하트를 누르고, 마이페이지로 이동하면 방금 하트 누른 포즈를 볼 수 없었습니다.
이러한 문제가 발생한 이유는 두 컴포넌트가 별개의 컴포넌트이기 때문에 서버에 요청이 각각 보내져 항상 신규 데이터가 반영될 것이라고 예상한 것과 달리, Recoil이 한 번 가져온 찜한 포즈 리스트 데이터를 캐싱하고 있어서 페이지를 이동해도 변경된 데이터가 반영되지 않는 것이었습니다.
해결하기 위한 노력
이 문제를 해결하기 위해서 Recoil이 캐싱하고 있는 비동기 데이터를 강제로 갱신하는 방법에 대해서 알아보았습니다.
가장 많이 도움이 되었던 자료는 FEConf에서 Recoil에 대해 발표하신 아래 자료였습니다.
https://www.youtube.com/watch?v=0-UaleJZOw8&ab_channel=FEConfKorea
해당 발표에서 공식문서에서 소개하고 있는 refetch 방법에 대해 설명을 잘 해주셨습니다.
내용을 정리해보면 다음과 같습니다.
우선, Selector가 재평가되어 리렌더링하는 경우는 두 가지가 있습니다.
1. 내부에서 구독중인 다른 recoil state의 변경을 감지한 경우
2. 요청 파라메터가 완전 새로운 값으로 변경된 경우
1번을 이용하면 요청을 보내는 selector가 의존성으로 atom을 하나 가지고 있게 하여, 이 atom값을 변경함수를 함수를 실행할 때마다 selector가 재평가 되는 방식으로 refetch를 유도할 수 있습니다.
- product selector : 상품정보를 서버에 요청해서 받아오는 selector -> requestIdAtom을 의존성으로 가지고 있음
- requestIdAtom : api를 요청한 횟수를 저장하는 atom
- refresh function : requestIdAtom을 변경하는 함수 -> 서버에 요청을 다시 보내기 위해서
- refresh function을 실행하면 requestIdAtom이 바뀌면서 product selector가 재평가 되어 refetch된다!
그리고 이 방식을 조금 더 개선해보면 atom값을 변경하는 refresh function을 별도로 만들지 않고, 기존의 selector의 setter에 requestId를 증가시키는 로직을 추가하여 해당 로직은 캐싱이 안되게 만들 수 있습니다.
이 방법을 적용해서 찜한 포즈 리스트를 캐싱하지 않고 불러오는 selector를 아래와 같이 만들었습니다.
// fetchLikesSelector를 refetch 하기 위한 state
export const likesReqIdState = atom({
key: "likesReqIdStateAtom",
default: 1,
});
// 좋아요한 포즈 리스트를 불러오기
export const fetchLikesSelector = selectorFamily<string[], string>({
key: "FetchLikesSelector",
get:
(token: string) =>
async ({ get }) => {
if (!token || token.length <= 0) {
return [];
}
const { data } = await likeApiService.fetchLikes(token);
get(likesReqIdState);
return data.map((item: LikedPose) => item.poseId);
},
set:
() =>
({ set }) => {
set(likesReqIdState, (id) => id + 1);
},
});
하지만 이 방법은, 캐싱 여부를 원하는 때마다 선택할 수는 없고 매번 캐싱이 되기를 포기하는 방법입니다. 그 뿐 아니라, 데이터 refetch를 위해 작성해야하는 코드가 너무 많아 서비스 전체에 이러한 기능이 필요할 때마다 사용하기에는 번거롭다고 생각했습니다.
따라서 논의 후에 해당 코드는 사용하지 않기로 했습니다.
그리고 refetch를 위한 또 다른 방법을 사용해 보았는데요, 그 방법은 바로 useRecoilRefresher 훅입니다.
useRecoilRefresher 훅은 selector를 refresh해주는 제가 원하던 바로 그 기능! 인데요.
나온지 몇년이 지난 지금까지도 UNSTABLE API로 분류되어있어 안정성이 떨어진다는 단점이 있습니다.
(역시 아직도 버전1이 나오지 않은 Recoil 답습니다.)
그래서 결국...?
당시 저희는 프로젝트 일부에 useRecoilRefresher을 적용하기는 했었습니다. 하지만 이러한 상태 불일치 문제해결과 더불어 새롭게 추가하게 된 무한스크롤 기능을 편리하게 구현할 수 있다는 점에서 결국 React-Query를 도입하기로 하였습니다.
React-Query에서는 너무나도 간편하게 쿼리를 재평가 시킬 수 있었습니다.
https://tanstack.com/query/v4/docs/framework/react/guides/query-invalidation
비록 열심히 알아본 내용을 서비스에서 사용하고 있지는 않지만, 비동기 데이터의 상태관리에 대한 이해를 높일 수 있었던 경험이었습니다.