관리 메뉴

개발 여행자, 현

[모두의시간] React-Query 도입기 본문

대외활동/DND 8기

[모두의시간] React-Query 도입기

예스현 2023. 8. 14. 16:06

react-query 도입기

 

모두의 시간 프로젝트를 짧은 기한 내에 개발하면서 유지보수의 필요성을 크게 느끼고 있었다.

메인 개발을 전부 마친 이후에 공통 컴포넌트를 우선적으로 재정돈하고 react-query를 도입했다.

약속 초대 페이지 -> 로그인 페이지 -> 일정등록 페이지
실시간 조율현황 페이지 -> 우선순위 보기 페이지

 

React Query를 도입 해야겠다고 생각한 가장 큰 이유 중 하나는 API를 요청해야 하는 페이지가 하나 둘 늘어났고

useEffct, async await, try catch, loading state... 코드의 줄 수가 불필요하게 길어지고 복잡해졌기 때문이다.

 

또한 생성한 방의 정보를 불러오는 API를 호출하는 페이지가 많은데, 데이터가 업데이트 되지 않았을 때는 일정시간 내에는 동일한 데이터를 사용하는게 맞다고 판단했다. API 요청을 최소화 하고싶었고, 추후 방생성하는 페이지들은 

퍼널 형식으로 리팩터링 할 예정이기 때문이다.

 

메인 기능을 사용하기 위해 방문하는 페이지마다 API를 요청하고 있었고, 데이터가 업데이트 되지 않았을 때는 실시간으로 불러오지 않아도 되기에 react query의 캐시 기능을 사용하고자 한다.

 

또한 기존에 Redux, Mobx, Recoil과 같은 다양하고 훌륭한 상태 관리 라이브러리들이 있긴 하지만, 클라이언트 쪽의 데이터들을 관리하기에 적합할 순 있어도 서버 쪽의 데이터들을 관리하기에는 적합하지 않은 점들이 있었다.

 

 

 

SWR vs React Query


리액트 쿼리를 도입하기 이전에 패칭 라이브러리의 양대산맥인 SWR과 React-Query 중에

어떤 데이터 패칭 라이브러리를 도입할지 고민했다.

 

두 라이브러리 모두 React 환경에서 API 요청을 쉽게 다루기 위한 라이브러리이다.

 

데이터 요청 관련인 loading, success, error 등의 상태를 쉽게 관리하고 cache 기능을 제공하여 불필요한 요청을 줄여준다.

SWR은 주로 데이터를 가져오는 것에 설계가 되었기에 설정이 단순하고 라이브러리가 가볍고 번들 사이즈는 4.3KB이다.

 

React Query는 더 다양한 캐싱전략을 보유하고 커스터마이징 기능을 제공하여 번들 사이즈가 13KB이다.

 

그래서 일반적으로 서비스에서 데이터를 가져오고 캐싱하는 간단한 방법이 필요한 경우 SWR을 선택하는 것이 좋고,React-Query는 고급 기능이 필요하거나 데이터 종속성이 많은 서비스에서 작업하는 경우가 좋은 선택이다.

 


현재는 대규모의 서비스도 아니고, 데이터를 여러번 가져오는 것만 개선하면 되기에 SWR을 사용해도 되었다.

하지만 서비스가 커질 수 있다는 점, 그리고 스타트업과 IT 대기업 둘 다 react-query를 경험 해보았기에 실무에 초점이 더욱 맞춰져 있는 React Query를 도입했다. 

 

 React Query를 도입하면, 쉽게 데이터를 가져올 수 있고 동일한 요청을 하는 여러 컴포넌트가 동시에 렌더링 되더라도 한 번만 요청한다. 또한, 백그라운드에서 서버에 주기적으로 polling을 하면서 데이터가 유효한지 검사하고, 유효하지 않으면 업데이트하여 데이터를 동기화하는 장점이 있다. 위에서 언급했던 코드의 복잡성과 길이를 최소화 할 수 있을 것이라고 생각했다.

 

 

그래서 어떻게 적용하는건데?


react query를 사용하기 위해서는, 가장 최상위 index.tsx 파일에서 서비스 전체를 queryClientProvider로 감싸줘야한다.

 

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

root.render(
      <QueryClientProvider client={queryClient}>
          <App />
      </QueryClientProvider>
);

QueryClient 인스턴스를 생성하고, QueryClientProvider를 통해 서비스 전체에서 생성한 QueryClient에 접근 가능하도록 해준다.

 

 QueryClient는 단순하게 표현하자면 QueryCache와 MutationCache를 담는 그릇이다. 우리는 대부분의 경우에 직접 QueryCache에 접근하기보다, QueryClient를 통해 QueryCache와 MutationCache에 접근한다.

 

QueryClientProvider를 통해 내려준 queryClient에 접근하기 위해서는 useQueryClient를 사용한다.

 

QueryClient는 직관적으로 표현하면 QueryCache MutationCache를 담는 공간이다.

서비스를 개발하면서 대부분의 경우에 직접 QueryCache에 접근하기보다, QueryClient를 통해 QueryCache MutationCache에 접근한다.

 

QueryClientProvider를 통해 내려준 queryClient에 접근하기 위해서는 useQueryClient를 사용하는 것이다.

const queryClient = useQueryClient();

useQuery로 서버 데이터 가져오기

서버에서 데이터를 가져오고 캐싱을 하는데 사용하는 기본이며 가장 많이 사용하게 되는 훅이다.

 

const { data, isLoading } = useQuery(queryKey, queryFunction, options)

useQuery 의 첫 번째 인자는 Query Key로, Query Key를 하나만 넣을 수도 있지만 리스트로 여러 개를 넣을 수도 있다. 쿼리 키가 가지는 유연함이 곧 캐싱을 처리를 쉽게 만들어준다.

 

두 번째 인자는 네트워크 요청을 하는 클로저를 넣어주면 된다. Promise 타입의 메서드를 넣어주면 된다. 서버에서 데이터를 요청하고 Promise를 리턴하는 함수를 전달한다. 즉 axios.get(...), fetch(...) 등을 리턴하는 함수.

 

마지막은 옵션을 넣을 수 있다. 여기서도 상당히 많은 옵션이 있는데, 주로 onSuccess나 onError을 통해 핸들러를 넣어줄 수 있다.

 

 

예를 들어 room이라는 키는 아래와 같이 확장할 수 있다.

 

// 다른 키로 취급한다. 
useQuery(['room', 1], ...)
useQuery(['room', 2], ...)

// 객체 필드의 값이 달라도 다른 키로 취급한다
useQuery(['room', { isAvailable: true }], ...)
useQuery(['room', { isAvailable: false }], ...)

// 객체 필드의 순서가 달라도 내용이 같으면 같은 키로 취급한다
useQuery(['room', { isAvailable: true, status: 'done' }], ...)
useQuery(['room', { status: 'done', isAvailable: true }], ...)
 

useQuery 훅이 리턴하는 데이터와 옵션의 종류는 매우 다양하다.

공식 문서를 보면서 정리한 주요 사항은 다음과 같다. (자세한 내용은 공식 문서 참조)

  • Return Data
    • data - 쿼리 함수가 리턴한 Promise에서 resolve된 데이터
    • isLoading - 저장된 캐시가 없는 상태에서 데이터를 요청중일 때 true
    • isFetching - 캐시가 있거나 없거나 데이터가 요청중일 때 true
    • isError - API 요청이 실패했을 때 true
  • Option
    • cacheTime - unused 또는 inactive 캐시 데이터가 메모리에서 유지될 시간. 기본값은 5분이며 설정한 시간을 초과하면 메모리에서 제거된다.
      • Infinity로 설정하면 쿼리 데이터는 캐시에서 제거되지 않는다.
    • staleTime - 쿼리 데이터가 fresh 에서 stale로 전환되는데 걸리는 시간. 기본값은 0이다.
      • Infinity로 설정하면 쿼리 데이터는 직접 캐시를 무효화할 때까지 fresh 상태로 유지된다.
      • 캐시는 메모리에서 관리되므로 브라우저 새로고침 후에는 다시 가져온다.
    •  enabled  - false 값이 전달되면 쿼리가 비활성화된다.
      • 데이터 요청에 사용할 파라미터가 유효한 값일 때만 true를 할당하는 식으로 활용할 수 있다.
    • onSuccess - 쿼리 함수가 성공적으로 데이터를 가져왔을 때 호출되는 함수.
    • onError - 쿼리 함수에서 오류가 발생했을 때
    • onSettled - 쿼리 함수의 성공, 실패 두 경우 모두 실행된다.
    • keepPreviousData - 쿼리 키(ex.페이지 번호)가 변경되어서 새로운 데이터를 요청하는 동안에도 마지막 data값을 유지한다.
      • 페이지네이션을 구현할 때 유용하다. 캐시되지 않은 페이지를 가져올 때 화면에서 목록이 사라지는 깜빡임 현상을 방지할 수 있다.
      • isPreviousData 값으로 현재의 쿼리 키에 해당하는 값인지 확인할 수 있다.
    • initialData - 캐시된 데이터가 없을 때 표시할 초기값. placeholder로 전달한 데이터와 달리 캐싱이 된다. 브라우저 로컬 스토리지에 저장해 둔 값으로 데이터를 초기화할 때 사용할 수 있을 것이다.
    • refetchOnWindowFocus  - 윈도우가 다시 포커스되었을 때 데이터를 호출할 것인지 여부. 기본값은 true이므로 필요없다고 판단되면 끄면 된다.

 

그래서 코드는 어떻게 작성하는지?


UseQuery


유지보수에 용이하도록 react query 코드, API 요청 코드, 키 값 파일로 분리해서 관리했다.

 

// src/queries/room/useGetRoomInfo.ts

import { useQuery } from '@tanstack/react-query';
import { getRoomInfo } from '../../api/room';
import { QUERY_KEYS } from '../../constants/QUERY_KEYS';

export const useGetRoomInfo = (roomUUID: string) => {
  return useQuery([QUERY_KEYS.ROOM.GET_ROOM_INFO, roomUUID], () =>
    getRoomInfo(roomUUID)
  );
};

react query의 사용법은 어렵지 않다.

앞서 말했듯이 서버에서 데이터를 가져오는(C"R"UD의 R이라고 생각하면 편하다) 코드는 useQuery를 사용하면 된다. 

 

export const QUERY_KEYS = {
  ROOM: {
    GET_ROOM_INFO: 'get-room-info',
  },
  ...
  ...

};

쿼리에 필요한 KEY들도 별도의 파일로 관리했다.

키가 중복되는 경우가 없도록 작성하려고 해도 실수하는 human error 를 방지하고자 했다.

// api/room.ts

import { instance } from './instance';
import { PostRoomTypes } from '../types/roomInfo';

export const getRoomInfo = async (roomUUID: string) => {
  const { data } = await instance.get(`/api/room/${roomUUID}`);

  return data;
};

API를 요청하는 부분도 페이지별로 관리해서 유지보수에 용이하도록 했다.

 
실제로 사용하는 컴포넌트 내부에서는 다음과 같이 사용하면 된다.
 

적용 전

 useEffect(() => {
    const getRoomInfo = async () => {
      const { data } = await API.get(`/api/room/${roomUUID}`);
      setRoom(data);
    };
    getRoomInfo();
  }, []);

 

적용 후

 const { data } = useGetRoomInfo(roomUUID);

 

코드의 줄 수가 확연히 줄었고, 가독성이 좋아졌음을 알 수 있다.

 

useMutation


Query가 데이터를 Fetching하는 것에 비해, Mutation은 데이터를 생성, 변경, 삭제시킬 때 사용한다. 흔히 POST 요청이 포함된다.
CRUD에서 R을 뺀 CUD 작업을 처리한다고 생각하면 편하다.

 

위와 마찬가지로 useCreateRoom.ts Hook 파일을 작성했다.

 

// src/queries/room/useCreateRoom.ts

import { useMutation } from '@tanstack/react-query';
import { createRoom } from '../../api/room';
import { PostRoomTypes } from '../../types/roomInfo';

export const useCreateRoom = () => {
  return useMutation((payload: PostRoomTypes) => createRoom(payload));
};

 

useMutation Hook에 실행하고자하는 Promise 타입의 메서드를 넣어주면 된다. 물론 옵션 같은건 추가할 수 있고, useQuery 에 있는 Query Key는 딱히 넣어주지 않아도 된다.

키 값은 별도로 설정하지 않아도 되고 컴포넌트에서의 사용법은 다음과 같다

 

const { mutate, data, isError, isSuccess } = useCreateRoom();

useEffect(() => {
    if (
      '모든값이 입력 되었을 때' == true
    ) {
      mutate(room);

      if (isError) {
        confirm('오류가 발생했습니다.\n처음부터 다시 시도하세요');
        navigate(`${ROUTES.LANDING}`);
      }

      if (isSuccess) {
        navigate(`${ROUTES.CURRENT}/${data.roomUuid}`);
      }
    }
  }, [room, isError, isSuccess]);

 

실질적인 동작은 mutate를 호출해서 진행한다.
isError와 isSuccess 값을 활용하여서 API 호출을 실패했을 때와 성공했을 때를 구분하여 로직을 구현할 수 있다. 

 

Mutation 자체에 대한 것은 특별할 것이 별로 없으나, Query Key를 잘 활용해 Query를 Invalidation하거나 데이터를 업데이트하는 상황에서 적절히 사용할 수 있다.

 

react query를 도입한 덕분에 코드의 수가 확연히 줄었고 가독성이 크게 좋아졌다.

이후 react query의 장점을 살려 서버의 요청 수를 줄이고, 캐싱 데이터를 활용한 예시는

추후 별도의 포스팅으로 작성할 예정이다.