1. React Query란

React Query는 데이터 Fetching, Caching, 동기화, 서버 데이터 업데이트 등을 쉽게 만들어 주는 라이브러리입니다.
React에서 상태를 관리하기 위해 MobX, Redux 등 여러 라이브러리가 존재하지만, 현재 진행중인 프로젝트에서 접하게 된 React Query에 대해 간단히 알아보고자 합니다.



2. React Query의 장점

  • MobX나 Redux를 사용하다 보면 BoilerPlate 형태의 코드가 많이 발생하게 되는데, React-Query는 비교적 코드의 양이 적고 구조가 단순하여 추후 유지 보수가 용이합니다.
  • Caching을 통해 애플리케이션의 속도를 향상시킵니다.
  • 동일한 데이터에 대한 중복 요청을 제거합니다.
  • 오래된 데이터의 상태를 파악하여 updating을 지원합니다.
  • Garbage Collection(GC)을 이용하여 서버 쪽 데이터 메모리를 관리해줍니다.
  • React Hooks와 유사한 인터페이스를 제공합니다.
  • 비동기 과정을 선언적으로 관리할 수 있습니다.

* BoilerPlate란? - 최소한의 변경으로 여러 곳에서 재사용되지만, 반복적인 코드로 인해 많은 양의 코드를 양산하는 것

3. MobX   vs   React Query

상태 관리 라이브러리로 MobX를 사용할 때에는 보편적으로,
1. 서버에 Api를 요청하는 코드로 이루어진 파일
2. Api 요청 결과를 저장하거나 이와 관련된 상태를 관리하는 StateKeeper(Store) 파일
3. StateKeeper(Store)에서 관리되는 것들을 실질적으로 관찰하고 활용하는(Observer) Container/View 파일들을 기능적으로 나누어 사용합니다.
그 중, 아래 코드는 MobX의 핵심인 StateKeeper(Store)로, post list와 user list를 불러오기 위해 GET 요청을 보내는 예시입니다.
   class StateKeeper {
        static instance: StateKeeper;
        api: Api;

   constructor(api: Api) {
            this.api = api;
        }

   @observable
        posts: Post[] = [];

   @observable
        users: User[] = [];   

   @observable
        isLoading: boolean = false;

   @action
        async findPosts() {
            this.setIsLoading(true);
            const result = await this.api.findPosts();

   runInAction(() => {
                this.posts = result;
                this.setIsLoading(false);
            });
        return result;
        }

   @action
        async findUsers() {
            this.setIsLoading(true);
            const result = await this.api.findUsers();

   runInAction(() => {
                this.users = result;
                this.setIsLoading(false);
            });

   return result;
        }        
   @action
        setIsLoading(isLoading: boolean) {
            this.isLoading = isLoading;
        }
    }

Api 요청 결과를 각 observable인 posts, users에 담고 있으며, isLoading이라는 observable에 요청의 진행 상태를 담고 있습니다.
필요한 데이터의 종류가 증가하게 된다면(todos, albums, photos, ...) observable과 action들은 그만큼 더 늘어날 것이며, 위 예시 코드가 반복/중복적으로 존재하는 BoilerPlate 형태의 코드 구조를 초래하여 피로감을 제공할 수 있습니다.
또한, 상황에 따라 StateKeeper(Store)는 위 예시 처럼 상태 관리 보다는 Api 통신을 위한 코드로만 사용되는 현상이 발생할 수도 있습니다.
이러한 우려를 개선하기 위해, posts와 users를 MobX가 아닌 React Query를 사용하여 관리한다면 어떻게 될까요?

   function App() {
          const api = Api.instance;

   const { data: posts, isLoading: isPostsLoading } = useQuery(
              ["posts"],
              async () =>
                  await api.findPosts(),
          );

   const { data: users, isLoading: isUsersLoading } = useQuery(
              ["users"],
              async () =>
                  await api.findUsers(),
          );
      }

Api 요청을 보내는 코드는 MobX 사용시와 동일하게 별도의 파일로 존재하도록 하였으며, 이를 호출하여 사용합니다.

MobX 사용 시에 보았던 isLoading은 UseQueryResult에 이미 존재하기 때문에 별도로 생성하지 않아도 되며, posts와 users를 요청한 결과물은 UseQueryResult에서 data(예시에선 posts, users로 명명)를 불러와 사용할 수 있습니다.

그럼 이 결과물들을 MobX에서의 observable 처럼 전역 상태에서 사용할 수는 없을까요?

   const queryClient = useQueryClient();

   const savedPosts = queryClient.getQueryData('posts');
    const savedUsers = queryClient.getQueryData('users')

useQueryClient를 사용하여 필요시 데이터를 불러올 수 있습니다.

이 때, Api 요청이 다시 fetch되는 것이 아니라, queryKey에 저장된 데이터를 가져오는 것이기에 Api 중복 호출에 대해 우려하지 않아도 됩니다.


이 외에도 React Query는 많은 기능을 가지고 있으며, 이 기능들을 알아보고자 합니다.

4. React Query 기본 설치 및 Setting

- npm install react-query

   import { QueryClient, QueryClientProvider } from 'react-query'
    const queryClient = new QueryClient()
    function App() {
      return
        <QueryClientProvider client={queryClient}>
        ... //root 컴포넌트
        </QueryClientProvider>
    }
  1. QueryClient 인스턴스를 생성합니다.
  2. 컴포넌트가 QueryClient 인스턴스에 접근할 수 있도록 root가 되는 컴포넌트를 QueryClientProvider로 감싸줍니다.
  3. client prop으로 QueryClient를 넘겨줍니다.

5. React Query에서 주로 사용하는 Hook

(1) useQuery()

  • GET 요청과 같이 서버로부터 데이터 조회 시에 사용합니다.
     const {data, error, isError, isSuccess} = useQuery(queryKey, queryFn)
  • queryKey란?

    useQuery마다 부여되는 고유한 Key 값 입니다.
    문자열로 사용되기도 하고 배열의 형태로 사용될 수도 있지만, React Query version 4부터 모든 queryKey는 배열로 선언되어야 합니다.

    queryKey를 통해서 다른 곳에서도 해당 쿼리의 결과를 꺼내오는 것이 가능합니다
     // String => 자동으로 길이가 1인 배열로 인식
     const { data } = useQuery('users', queryFn);

     // Array1
          const { data } = useQuery(['users'], queryFn);

     // Array2
          const { data } = useQuery(['users1', 'users2'], queryFn);
  • React-query는 QueryKey로 캐싱을 관리합니다.
     const { data: data1 } = useQuery(['users1'], queryFn1);

     const { data: data2 } = useQuery(['users1'], queryFn2);
  1. data1과 data2의 queryFn은 서로 다르나, 동일한 queryKey로 응답을 요청했습니다. 이럴 경우, 서버에 한 개의 요청만이 전달됩니다.
  2. 따라서 해당 코드는 queryFn에 관계 없이 data1에서 요청했던 결과를 그대로 data2에 저장하는 꼴이 됩니다.
  3. 즉, data1에서 서버에 응답을 요청하게 되면 data2에는 이미 동일한 queryKey에 대한 결과값이 있기 때문에, 추가적으로 응답을 요청하지 않고 data1의 결과를 그대로 사용하게 됩니다.
  • QueryFn이란?

    Promise 처리가 이루어지는 함수로써, axios와 같이 서버에 API를 요청하는 코드입니다.
     import { useQuery } from 'react-query'

     //queryFn
          const fetchUserList = () => {
              return axios.get("https://jsonplaceholder.typicode.com/users");
          }

     function App() {
             const { data } = useQuery(['users'], () => fetchUserList);
         }
  • useQuery()는 비동기로 작동합니다.
    한 컴포넌트에 다수의 useQuery가 존재한다면, 하나의 useQuery가 실행된 후 다음 useQuery가 실행되는 것이 아니라, 다수의 useQuery가 동시에 실행됩니다.

    이와 같이 다수의 useQuery가 존재할 경우, Query Options에서 "enabled"를 사용하면 useQuery를 동기적으로 사용할 수 있습니다.
     const usersQuery = useQuery('users', () => fetchUsers);
        const todosQuery = useQuery('todos', () => fetchTodos, {
              enabled: !!usersQuery
          });
          const postsQuery = useQuery('posts', () => fetchPosts, {
              enabled: !!todosQuery
          });

enabled: true -> 특정 조건이 true일 때만 쿼리가 실행됩니다.

위 경우에는, usersQuery, todosQuery, postsQuery 순으로 실행됩니다.

(1)-1. 자주 사용되는 Options

위에서 설명한 enabled 옵션 외 자주 사용되는 옵션들을 정리해보았습니다
  • retry (boolean | number | (failureCount: number, error: TError) => boolean) - default : 3회

    실패한 쿼리를 재시도하는 옵션입니다.

    true: 쿼리 실패시 무한으로 재시도

    false: 재시도 하지 않음

  • staleTime (number | Infinity) - default: 0

    • stale이란?

      stale의 사전적 의미는 "신선하지 않은"이라는 뜻을 가집니다.

      React Query는 기본적으로 캐싱된 데이터를 stale한 상태로 여깁니다.

      즉, stale이란 최신화가 필요한 데이터라는 의미로 stale한 상태가 되면 refetch가 실행됩니다.
    • staleTime이란?

      데이터가 fresh -> stale 상태로 변경되는데 걸리는 시간 즉, fresh 상태로 유지되는 시간입니다.

      데이터가 한번 fetch 되고 나서 staleTime이 지나지 않았다면(fresh한 상태) unmount 후 mount 되어도 fetch가 일어나지 않습니다.

      자주 변경되지 않는 데이터라면 staleTime 시간을 늘리는 것도 좋은 방법입니다.


  • cacheTime (number | Infinity) - default: 5분

    • cacheTime이란?

      데이터가 inactive 상태일 때 캐싱된 상태로 남아있는 시간입니다.

      쿼리 인스턴스가 unmount 되면 데이터는 inactive 상태로 변경되며, 캐시는 cacheTime만큼 유지됩니다.

      cacheTime이 지나기 전에 쿼리 인스턴스가 다시 mount 되면, 데이터를 fetch하는 동안 캐시 데이터를 보여줍니다.

      cacheTime이 지나면 해당 데이터는 Garbage Collector(GC)로 수집됩니다.


staleTime과 cacheTime


  • refetchOnMount (boolean | "always") - default : true

    데이터가 stale 상태일 경우 mount 시 마다 refetch 여부를 설정하는 옵션입니다.

  • refetchOnWindowFocus (boolean | "always") - default : true

    데이터가 stale 상태일 경우 윈도우 포커싱이 될 때 마다 refetch 여부를 설정하는 옵션입니다.

  • onSuccess ((data: TDdata) => void)

    쿼리 성공 시 실행되는 함수로, 매개변수 data는 성공 시 서버에서 넘어오는 response 값입니다.

  • onError ((error: TError) => void)

    쿼리 실패 시 실행되는 함수로, 매개변수로 error 값을 받을 수 있습니다.

  • initialData (TData | () => TData)

    initialData를 설정하면 쿼리가 아직 생성되지 않았거나 캐시되지 않았을 때 설정한 initialData가 쿼리 캐시의 초기 데이터로 사용됩니다.

(2) useMutation

  • 데이터 조회(GET) 외 POST, PUT, DELETE와 같이 데이터 변경 및 삭제 작업을 할 때 사용합니다. 데이터를 저장하지 않으므로 queryKey는 필요로 하지 않습니다.
     import { useMutation } from 'react-query';
          const Todo = () => {
            const updatePost = (postId: number) => {
              return axios.put(                      https://jsonplaceholder.typicode.com/postId/${postId},
                ).then((res) => res && res.data);
            }

     const updateMutation = useMutation( (postId: number) => updatePost(postId));
            return (
              <div>
                <Button onClick={() => updateMutation.mutate(post.id)}>
                  update
                </Button>
              </div>
            );
          }

(2)-1. Query Invalidation

  • useMutaion을 사용해서 데이터를 create, update, delete 후 변경된 데이터를 확인하고 싶으면 데이터를 새로 fetch 해야합니다.

    이런 상황을 간단하게 해결할 수 있는 방법으로 invalidateQueries를 사용할 수 있습니다.

    useQuery가 가지고 있던 queryKey의 유효성을 제거(무효화)해서 캐싱되어 있던 데이터를 보여주지 않고 새로운 데이터를 받아올 수 있게 합니다.
     import { useMutation, useQueryClient } from 'react-query';
        const queryClient = useQueryClient();
        const updateMutation = useMutation( (postId: number) => updatePost(postId), {
            onSuccess: async () => {  //요청이 성공한 경우
                await queryClient.invalidateQueries();  //캐시가 있는 모든 쿼리 무효화
                await queryClient.invalidateQueries('posts'); //queryKey가 'posts'로 시작하는 모든 쿼리 무효화
            }
        });

6. React Query Devtools

React Query는 강력한 내장 개발 도구와 함께 제공됩니다.
이 React Query Devtools는 사용중인 모든 쿼리의 상태를 시각화하며, 이 쿼리가 예상대로 작동하지 않는 경우 문제를 해결하는 데 도움이 될 수도 있습니다.
개발 도구를 실행하기 위해서는, 앞서 설명한 QueryClientProvider로 ReactQueryDevtools를 감싸줘야 합니다.
   import { QueryClient, QueryClientProvider } from 'react-query'
        const queryClient = new QueryClient()
        function App() {
          return
            <QueryClientProvider client={queryClient}>
            ... //root 컴포넌트
            //initialIsOpen : open된 채로 시작
            //position : devtools를 열 수 있는 logo 위치 - 우측 하단으로 지정
            <ReactQueryDevtools initialIsOpen={false} position='bottom-right' />
            </QueryClientProvider>
        }

위와 같이 적용 후, 실행해보면 우측 하단에 아래와 같은 꽃 모양(React Query 로고)의 버튼이 생긴 것을 볼 수 있습니다.

로고 버튼을 클릭해보면, 아래와 같은 개발 도구가 열리게 됩니다.

React Query Devtools는

  • queryKey로 쿼리를 표시해줍니다.
  • fresh, fetching, stale, inactive와 같이 쿼리 상태를 보여줍니다.
  • 해당 API를 요청하는 observer 수 조회가 가능합니다.
  • 쿼리가 마지막으로 업데이트 된 시간을 볼 수 있습니다.
  • refetch, invalidate, reset, remove 등의 actions를 GUI를 통해 쉽게 적용할 수 있습니다.
  • Data Explorer 탭에서는 Chrome devtools의 Network 탭에서 존재하는 정보 또한 확인이 가능합니다.

간단한 예시로, query options의 staleTime을 3000(3초)로 지정하면, fetch 직후 fresh 상태였던 쿼리가 3초 후 stale 상태로 바뀌는 것을 볼 수 있습니다. staleTime을 0으로 지정 후 실행해보면 fetch 직후 바로 stale 상태로 바뀌는 것을 볼 수 있습니다.

이와 같이, React Query Devtools에서 제공하는 정보를 잘 활용하면, Data Fetching 작업을 더 수월하게 진행할 수 있을 것입니다.



7. 결론

React Query는 데이터 요청, 변경에 관련된 코드를 단순화시켜 줍니다. 이를 통해 서버와의 데이터 요청과 응답을 처리하는 코드를 작성하는 데 더 적은 시간을 투자할 수 있으며, 애플리케이션의 성능과 유지보수성을 높일 수 있습니다.
React Query로 데이터 요청의 상태를 추적하여 UI를 적절하게 업데이트 할 수 있습니다. 예를 들면, 복잡한 코드 작성 없이 isLoading으로 데이터의 로딩 상태를 추적하고, 로딩 중인 경우 로딩 중임을 알리는 UI를 렌더링 할 수 있습니다. 이러한 UI 업데이트는 React Query에서 자동으로 처리됩니다.
React Query는 이 글에서 설명한 것 외에도 다양한 기능과 설정 옵션을 제공합니다. 처음에는 사용법이 어려울 수 있으나 공식 문서를 참고하면서 점차 기능을 추가하고 설정 옵션을 조정해 나가면 React Query를 효과적으로 사용할 수 있을거라 생각합니다.

Pancake


참고자료

- https://tanstack.com/query/latest/docs/react/overview
- https://velog.io/@devjooj/ React-React-Query-사용-이유-Queries-개념편
- https://2ham-s.tistory.com/407
- https://velog.io/@yrnana/ React-Query에서-staleTime과-cacheTime의-차이
- https://tanstack.com/query/v4/docs/react/devtools