react query 마이그레이션 하기

사내 react query의 버전을 v3에서 v5로 마이그레이션하며 겪은 것들을 기록해보았습니다.

11
조회
  • useSuspenseQuery와 Suspense를 이용하여 로딩 상태를 위임하고
  • queryOptions를 이용해 쿼리 옵션의 재사용성을 개선시키며
  • 사내 react query 버전을 통일화하여 개발자들의 인지 부하를 줄이기 위해
  • 총 3개의 레포의 react query 버전을 v3에서 v5로 마이그레이션 진행했습니다.


마이그레이션을 통해 얻고자 했던 것들

  • useSuspenseQuery

useSuspenseQuery가 v5에서 정식으로 지원되었습니다.

useSuspenseQuery와 Suspense를 같이 사용하면 로딩 상태를 Suspense에 위임할 수 있습니다. query를 사용하는 컴포넌트에서는 데이터 페칭이 끝난 이후의 상태에만 집중을 하면 됩니다. 이렇게 되면 컴포넌트 내부의 로직을 더욱 간결하게 작성할 수 있습니다.

// useQuery
function Component() {
  const { data, isLoading } = useQuery(...);
 
  if (isLoading) return <Skeleton />;
 
  return <Flex>{data.map(...)}</Flex>
}
// useSuspenseQuery + Suspense
function Parent() {
  return <Suspense fallback={<Skeleton />}><Child /></Suspense>
}
 
function Child() {
  const { data } = useSuspenseQuery(...);
 
  return <Flex>{data.map(...)}</Flex>
}

  • queryOptions를 통해 쿼리 재사용성 개선

queryOptions이 나오기 전까지는 커스텀 훅으로 쿼리를 재사용하곤 했습니다.

function useGetStudent({ studentId }: { studentId: string}) {
  return useQuery({
    queryKey: ['students', studentId],
    queryFn: () => getStudent({ studentId }),
    enabled: Boolean(studentId)
  })
}
 
function Components() {
  const { data: students } = useGetStudents();
 
  ...
}

이 방식의 단점은 getServerSideProps와 같은 서버 사이드에서 쿼리를 재사용할 수 없다는 점입니다. 하지만 다음과 같이 queryOptions로 쿼리를 정의했다면 가능해집니다.

const studentQuery = ({ studentId }: { studentId: string }) =>
  queryOptions({
    queryKey: ['students', studentId],
    queryFn: () => getStudent({ studentId }),
    enabled: Boolean(studentId),
  });
 
// 컴포넌트에서도 사용이 가능하고
function Component({ studentId }: { studentId: string }) {
  const { data: student } = useSuspenseQuery(studentQuery({ studentId }))
}
 
// 서버 사이드에서 사용 가능
export const getServerSideProps = async () => {
  ...
 
  const queryClient = new QueryClient();
  try {
    await queryClient.fetchQuery(studentQuery({ studentId }));
  } catch (error) {
 
  }
 
  ...
}

  • 일관성

무엇보다 일관성을 높이고자 하는 바가 가장 컸습니다. 기존에 제가 자주 코드를 작성했던 레포의 react query 버전은 v5였습니다.(이것도 v3에서 마이그레이션한 것이긴 합니다) 근데 요즘엔 여러 레포를 오가며 코드를 작성해야 하는 일이 많아졌습니다. 이 여러 레포들의 react query 버전은 대부분 v5였습니다.

어디선 useSuspenseQuery를 쓸 수 있었던 반면, 어디서는 useQuery의 suspense option을 써야 했습니다. 후자의 경우는 suspense option을 true로 설정하고 Suspense 컴포넌트와 같이 사용을 했더라도 data의 optional 타입은 제거되지 않았기에 로직을 작성하는 데에 있어 불편함이 존재했습니다.

queryOptions의 유무 차이, v3에서는 cacheTime이고 v5에서는 gcTime인 등 여러 요소들이 혼동을 주었습니다. 그리고 코드베이스 크기는 점점 커져갔고 저처럼 여러 레포를 오가는 팀원이 많아졌습니다. 이에 '이제는 혼동을 감수하는 것보다 마이그레이션에 시간을 쓰는 것이 더 가치있을 때이다'라는 판단이 들어 이를 진행하게 되었습니다.


마이그레이션 전략

react query v3, v5를 모두 경험했고 사용도 많이 해봐서 순조로울 줄 알았지만 꽤 힘이 많이 든 작업이었습니다. 제가 마이그레이션을 할 때 어떤 것들을 사용하고 경험했는지에 대해 얘기해보겠습니다.

  • codemod

처음엔 v5의 codemod를 이용해 migration을 시도했습니다. 하지만 codemod가 모든 걸 migration 해주진 못했습니다. 대표적으로 다음과 같은 경우는 직접 수정을 해주어야 했습니다.

  1. import { ... } from 'react-query'import { ... } from '@tanstack/react-query'로 수정하기
  2. cacheTime을 gcTime으로 수정하기
  3. useMutation의 결과값 중 하나인 isLoading을 isPending으로 수정하기

  • focusManager

그리고 focusManager 동작이 수정되었습니다. 기존엔 이상한 UX였기에 수정된 것은 이해하지만 저희에겐 이 이상한 UX를 활용하고 있었습니다. 제 목적은 단순히 마이그레이션을 하는 것일 뿐, 잘 동작하던 기능이 수정되는 것은 원치 않갔기에 focusManager를 직접 건드려줘야 했습니다.

import { focusManager } from '@tanstack/react-query';
 
focusManager.setEventListener((handleFocus) => {
  if (isClient() && window.addEventListener) {
    const visibilitychangeHandler = () => {
      handleFocus(document.visibilityState === 'visible');
    };
    const focusHandler = () => {
      handleFocus(true);
    };
    const blurHandler = () => {
      handleFocus(false);
    };
 
    window.addEventListener('visibilitychange', visibilitychangeHandler);
    window.addEventListener('focus', focusHandler);
    window.addEventListener('blur', blurHandler);
    return () => {
      window.removeEventListener('visibilitychange', visibilitychangeHandler);
      window.removeEventListener('focus', focusHandler);
      window.removeEventListener('blur', blurHandler);
    };
  }
});

  • useQuery에서 onError, onSuccess 콜백 제거

onError, onSuccess 콜백은 의도치 않게 여러 번 동작될 수 있고 이것은 이상한 UX이기에 v5에서는 제거되었습니다. 하지만 저희 코드에서는 onError에서 에러 토스트를 띄운다던가, onSuccess에서 성공한 데이터를 state로 이용하는 등 onError와 onSuccess를 이용하고 있었습니다.

function Component() {
  const [student, setStudent] = useState();
  const { data } = useQuery({
    ...
    onError: (error) => {
      toast.error(error.message);
    },
    onSuccess: (data) => {
      setStudent(data);
    }
    ...
  })
 
  ...
}

onError는 meta options를 이용해 글로벌에서 처리했습니다. 그리고 onSuccess와 state의 조합은 안티 패턴이지만 동작을 수정하진 말자는 것이 이번 마이그레이션에서의 원칙이었기에 useEffect를 이용하여 해결하였습니다.

const [queryClient] = useState(
  () =>
    new QueryClient({
      queryCache: new QueryCache({
        onError: (_, query) => {
          const errorMessage = query.meta?.errorMessage;
          if (errorMessage) {
            alert(errorMessage);
          }
        },
      }),
    })
);
 
export const studentQuery = ({ studentId }: { studentId: string }) => {
  return useQuery({
    ...
    meta: { errorMessage: '에러가 발생했습니다.' },
  });
function Component() {
  const { data } = useQuery(notificationQuery);
  const [state, setState] = useState();
 
  useEffect(() => {
    if (data) {
      setState(data);
    }
  }, []);
}

  • 많은 분들께 싹싹 빌며 도와달라고 하기

마이그레이션이라는 단어 자체는 꽤 화려하게 들립니다. 성공만 한다면 새로운 환경에서 최신 API를 활용해 더 좋은 코드를 작성할 수 있을 것처럼 보입니다. 하지만 그 과정은 생각보다 녹록치 않았습니다.

react query는 저희 서비스에서 네트워크 요청에 있어 아주 코어한 역할을 하고 있습니다. 안정성 확보를 위해 react query가 사용되고 있는 거의 모든 기능들을 테스트해야 했습니다. 이 떄 정말 큰 도움이 되었던 것은 datadog의 synthetic testing이었습니다. datadog에서 작성할 수 있는 E2E 테스트라고 봐주시면 될 것 같습니다.

다행히 QA 매니저님이 많은 기능에 이 datadog의 synthetic test를 붙여둔 상태였습니다. 마이그레이션 이후 이 테스트를 돌려봄으로써 많은 기능들이 안전하게 동작하고 있음을 확인할 수 있었습니다.

하지만 synthetic test가 붙여지지 않은 곳에서는 어쩔 수 없이 수동으로 확인하는 방법 밖에 없었습니다. 이를 저 혼자 하기에는 역부족입니다. 마이그레이션을 진행하는 레포에서 작업하시는 팀원분들께 도움을 요청했고, 기존의 기능부터 앞으로 새로 런칭될 기능까지 모두 수동으로 테스트해보며 안정성을 확보할 수 있었습니다.

그럼에도 불안한 건 마찬가지입니다. 너무 코어한 라이브러리의 마이그레이션이기 떄문입니다. 이는 '어쩔 수 없다. 터지면 내가 어떻게든 책임진다.'라는 마인드, 그리고 미움 받을 용기를 가진 상태로 배포를 진행했습니다.


References