[Refactoring] tanstack-query로 데이터 최신화 하기

junjeong·2025년 2월 5일

Linkbrary

목록 보기
4/6

🐾 들어가며...

지난 시간에는, tanstack-query를 활용해 api 요청을 할 때마다 매번 새로운 요청을 보내는 것이 아닌, 캐싱된 데이터를 불러오도록 하는 리팩토링 작업에 대해서 다루었었다.

그렇다면 이번 시간의 주제는 무엇이냐?, 바로 "서버에 데이터가 갱신되었을 때, tanstack-query는 캐싱된 데이터가 아닌 최신의 데이터를 어떻게 불러올 수 있는가?" 이다.

1️⃣ tanstack-query의 데이터 캐싱 원리

tanstack query는 최신의 데이터를 새로 요청해야 하는 경우와 캐싱된 데이터를 반환해야 하는 경우를 무엇을 기준으로 구분할까?

tanstack query를 활용해서 데이터를 가져올 때는 항상 쿼리 키(queryKey)를 지정해주어야 했다. 이 쿼리 키가 캐시된 데이터와 비교해 새로운 데이터를 가져올지, 캐시된 데이터를 사용할지 결정하는 기준이 된다.

위에 사진은 쿼리 키와 일치하는 데이터가 없을 때, 서버에서 데이터를 새로 가져오는 과정을 보여준다. 서버에서 데이터를 가져오면 그 데이터는 캐시 메모리에 저장되고 그 이후 요청부터는 캐시된 데이터를 사용할 수 있다.

반대로 쿼리 키와 일치하는 데이터가 있다면, 서버에 요청이 생략되고 바로 쿼리 키에 해당하는 데이터를 반환한다. 같은 데이터를 가져오는 요청이 여러 번 발생해도, 캐시된 데이터를 사용하게 되어 중복 요청을 줄일 수 있다.

그렇다면 한번 캐시된 데이터가 있으면, 서버로는 더 이상 요청을 보낼 수 없는 것일까?

데이터의 신선도

TanStack Query는 캐시한 데이터를 신선(Fresh)하거나 상한(Stale) 상태로 구분해 관리한다. 캐시된 데이터가 신선하다면 캐시된 데이터를 사용하고, 만약 데이터가 상했다면 서버에 다시 요청해 신선한(새로운) 데이터를 가져온다.

tanstack query 개발자 도구에서 보여지는 ui이다. 오른쪽 상단과 하단을 보면 어떤 데이터가 fresh 상태이고 stale 상태인지 알 수 있다. 일종의 데이터 유통기한인 것이다.


그렇다면 fresh가 stale로 바뀌는 시간은 언제일까? 이처럼 데이터의 신선도 기한을 개발자가 지정할 수 있는데 그것이 바로 useQuery()를 사용할 때 설정해주었던 staleTime 옵션이다.

2️⃣ 데이터의 신선도를 강제로 stale로 바꾸는 메소드, invalidateQueries()

만약 링크 목록을 불러올 때의 staleTime을 5분으로 설정하였는데, 사용자가 바로 새로운 링크를 추가했다고 가정해보자. 5분동안은 캐싱된 데이터를 렌더링 하기 때문에 사용자가 추가한 링크 목록를 5분 동안이나 볼 수 없게 되는 것이다. 이는 분명 사용자 경험을 저하시키는 요인이다.

이처럼 서버 상태와 클라이언트 상태를 항상 동일시 해야 한다는 원칙을 "서버 상태 동기화"라고 한다. 사실 서버 상태보다 더 중요한 것은 클라이언트 상태일지도 모른다. 이유는 tanstack-query에 서 지원하는 낙관적 업데이트라는 기능이 있는데 사용자가 서버 데이터를 업데이트 시킬 경우에(post,patch,delete), 서버 상태가 실제로 바뀌기도 전에 이미 바뀌었다고 판단하여 클라이언트 ui를 업데이트하는 기능이 존재하기 때문이다.

// `link`로 시작하는 쿼리키를 가지는 모든 쿼리를 무효화함
queryClient.invalidateQueries({ queryKey: ['links'] });

위와 같은 경우에 필요한 작업이, 아직 fresh한 queryKey의 신선도를 강제로 stale로 바꾸는 작업이다. invalidateQueries 메소드가 바로 해당 기능을 제공하는 메소드이다.

invalidateQueries()를 언제 써야 할까?

그렇다면 데이터를 stale로 바꾸는 시점은 언제여야 할까? 쉽게 생각해서 데이터가 업데이트 되거나 삭제되는 시점일 것이다. 서버에 데이터를 가져올 때에는, 5분동안 캐시하도록 두고 데이터의 조작이 생기는 경우에만 기존에 캐시를 초기화 시키면 자연스러운 데이터 흐름이 될 것 같다.

그렇다면 사용자가 서버에 데이터를 조작하는 경우가 언제인지 찾아보자.

  1. 링크 목록에 링크를 추가할 때
  2. 링크를 즐겨찾기 목록에 추가할 때
  3. 링크에 대한 정보를 변경할 때
  4. 링크 목록에서 링크를 삭제할 때
  5. 폴더를 추가할 때
  6. 폴더를 삭제할 때
  7. 폴더 이름을 변경할 때

정도인 것 같다.

현재 프로젝트에서는 useQuery()를 통해, 링크 목록을 불러올 때에는 "links", 폴더 목록을 불러올 때에는 "folders", 폴더 이름을 불러올 때에는 "folderName", 즐겨찾기 페이지에서 즐겨찾기 링크 목록을 불러올 때에는 "favorite"이라는 queryKey를 사용하고 있다. 하니 다음과 같이 캐시를 초기화 시켜주면 될 것 같다.

  1. 링크 목록에 링크를 추가할 때 👉 invalidateQueries({ queryKey: ['links'] });
  2. 링크에 대한 정보를 변경할 때 👉 invalidateQueries({ queryKey: ['links'] });
  3. 링크 목록에서 링크를 삭제할 때 👉 invalidateQueries({ queryKey: ['links'] });
  4. 링크를 즐겨찾기 목록에 추가할 때 👉 invalidateQueries({ queryKey: ['favorite'] });
  5. 폴더를 추가할 때 👉 invalidateQueries({ queryKey: ['folders'] });
  6. 폴더를 삭제할 때 👉 invalidateQueries({ queryKey: ['folders'] });
  7. 폴더 이름을 변경할 때 👉 invalidateQueries({ queryKey: ['folders'] }), invalidateQueries({ queryKey: ['folderName'] });

3️⃣ 특정 쿼리 키를 강제로 다시 요청하는 메소드, refetchQueries()

문제가 있다.

invalidateQueries()는 queryKey에 해당하는 요청의 신선도를 강제로 stale로 바꿔주는 메소드일 뿐, stale로 바뀌었다고 해도 useQuery()가 해당하는 queryKey의 요청을 다시 수행하지는 않는다.

즉, invalidateQueries()로 신선도를 바꾸어도 ui가 리렌더링 되는 것은 아니니 사용자는 여전히 이전의 데이터를 경험한다는 이야기이다.

이 때 필요한 메소드가 React Query의 QueryClient 객체에서 제공하는 메소드인, refetchQueries()이다. refetchQueries()는 특정 쿼리 키에 해당하는 쿼리를 강제로 다시 요청할 때 사용한다.

queryClient.refetchQueries(queryKey, options);

// queryKey: 요청할 쿼리의 키. 문자열이나 배열 형태로 지정할 수 있습니다. 예를 들어, 'folders' 또는 ['folders']와 같이 사용할 수 있습니다.

// options: 선택적인 매개변수로, 다음과 같은 옵션을 설정할 수 있습니다:
// ㄴ exact: true로 설정하면, 쿼리 키가 정확히 일치하는 쿼리만 다시 요청합니다. 기본값은 false입니다.
// ㄴ refetchInactive: 비활성화된 쿼리도 다시 요청할지 여부를 설정합니다. 기본값은 false입니다.

refetchQueries()를 사용하는 것보다 더 편한 방법이 있다면 바로 기존의 invalidateQueries의 옵션 중 refetchActive 옵션을 true로 설정하면 방법이다. 이렇게 하면 해당 쿼리를 stale로 변경하면서 동시에 재요청까지 해준다.

queryClient.invalidateQueries({ queryKey: ['folders'], refetchActive: true });

⚙️ linkbrary에 적용하기

로직이 비슷하기 때문에 대표적으로 링크 목록을 추가하는 경우만 다루겠다. linkbrary에서 링크를 추가하는 UX Flow는 다음과 같다.

AddLinkInput 에 원하는 링크를 입력한 뒤 "추가하기" 버튼을 누르면 AddModal이라는 modal 컴포넌트가 렌더링 되면서 추가하고 싶은 폴더를 선택한 뒤 다시 "추가하기" 버튼을 누르면 비로소 추가가 되는 flow이다.

최종적으로 링크 추가를 담당하는 곳은 AddModal이다. AddModal의 코드를 살펴보자.

const AddModal = ({ list, link }: { list: FolderItemType[]; link: string }) => {
  const [selectedId, setSelectedId] = useState<number | null>(null);
  const { closeModal } = useModalStore();
  const router = useRouter();

  const handleSubmit = async () => {
    const body = {
      folderId: Number(selectedId),
      url: link,
    };
    if (!selectedId) {
      toast.error(toastMessages.error.selectFolder);
    } else {
      try {
        await postLink(body);
        toast.success(toastMessages.success.addLink);
        router.push(`/link?folder=${selectedId}`);
      } catch (error) {
        toast.error(toastMessages.error.addLink);
      } finally {
        closeModal();
      }
    }
  };

  const handleClickFolderItem = (id: number) => {
    if (selectedId === id) {
      setSelectedId(null);
    } else {
      setSelectedId(id);
    }
  };
  return (
    <ModalContainer title="폴더에 추가" subtitle={link}>
      <FolderList
        list={list}
        selectedId={selectedId}
        onClick={handleClickFolderItem}
      />
      <SubmitButton
        type="button"
        onClick={handleSubmit}
        width="w-full"
        height="h-[51px]"
        color="positive"
      >
        추가하기
      </SubmitButton>
    </ModalContainer>
  );
};
export default AddModal;
  1. 폴더 목록인 FolderList를 props로 받고, 사용자가 입력한 링크와, 선택한 폴더를 링크를 추가하는 api 함수인 postLink에 인자로 전달해주고 있다.
  2. api 요청이 성공하면 우선 토스트 알림을 띄운 뒤, 해당 폴더 페이지로 보내주기 위해 router.push로 url을 변경을 시키는 모습이다.

router.push로 클라이언트 사이드 네비게이션이 되면 해당 페이지의 루트 컴포넌트는 리렌더링이 발생한다. 즉, 링크 목록을 불러오는 useFetchLinks()가 다시 실행되기 때문에 굳이 refetch를 해 줄 필요는 없어보인다. 기존에 캐시된 queryKey의 신선도를 stale로 바꾸는 작업만 추가해주면 될 것 같다.

const AddModal = ({ list, link }: { list: FolderItemType[]; link: string }) => {
  const [selectedId, setSelectedId] = useState<number | null>(null);
  const { closeModal } = useModalStore();
  const router = useRouter();
  const queryClient = useQueryClient();

  const handleSubmit = async () => {
    const body = {
      folderId: Number(selectedId),
      url: link,
    };
    if (!selectedId) {
      toast.error(toastMessages.error.selectFolder);
    } else {
      try {
        await postLink(body);
        toast.success(toastMessages.success.addLink);
        queryClient.invalidateQueries({queryKey: ["links"]}); // queryKey : links의 신선도를 fresh -> stale로 바꿔주는 모습 다음 fetch 때에 캐싱된 데이터가 아닌 새로운 데이터 요청이 이루어진다.
        router.push(`/link?folder=${selectedId}`);
      } catch (error) {
        toast.error(toastMessages.error.addLink);
      } finally {
        closeModal();
      }
    }
  };

  const handleClickFolderItem = (id: number) => {
    if (selectedId === id) {
      setSelectedId(null);
    } else {
      setSelectedId(id);
    }
  };
  return (
    // ...이전과 동일
  );
};
export default AddModal;

처음 폴더간에 이동은 캐싱된 데이터를 가져오기 때문에 새로운 api 요청을 생략하는 모습이고, 링크 추가가 된 이후에는 캐싱되 데이터가 아닌 서버에 새로운 api 요청이 network 탭에 추가되는 모습이다. 의도한대로 잘 되었다😆

🚪 마치며...

이번 시간은 tanstack query가 지원하는 invalidateQuery() 메소드를 활용해, 기존에 캐싱된 데이터가 아닌 최신의 데이터를 다시 요청하는 방법에 대해서 다루어 보았다.

이로써 linkbrary에 tanstack query를 활용한 리팩토링 과정은 끝이 났다.

다음 시간은 마지막 리팩토리 과정으로, 토스팀이 제시해 준 Frontend Fundamentals 가이드를 따라서 linkbrary에도 클린코드를 적용해보는 시간을 가져보겠다.

profile
Whether you're doing well or not, just keep going👨🏻‍💻🔥

0개의 댓글