[React] react-query란?

기록저장소·2023년 10월 25일

상태란?

주어진 시간에 대해 시스템을 나타내는 것으로 언제든지 변경할 수 있음
문자열, 배열, 객체 등의 형태로 응용프로그램에 저장된 데이터

⇒ 개발자입장에서는 관리해야하는 데이터들!


UI/UX 중요성과 함께 프로덕트 규모가 많이 커지고 FE에서 수행하는 역할이 늘어남

⇒ 관리하는 상태가 많아짐!


상태를 관리하는 방법에 대한 것 → 프로덕트가 커짐에 따라 어려워짐

상태들은 시간에 따라 변화함

React 에서는 단방향 바인딩이므로 Props Drilling 이슈도 존재

Redux, MobX, Rematch 와 같은 라이브러리를 활용해 해결함.

Store에서 다 관리하는게 맞나?

상태관리 영역이 서버 값을 저장하는데까지 확장

API 통신 관련 코드가 모두 store에?

또, 반복되는 isFetching, isError 등 API 관련 상태

또또, 반복되는 비슷한 구조의 API 통신 코드

상태를 두가지로 나누어 봅시다!

Client State vs Server State

KeyPoint. 데이터 OwnerShip이 있는곳!

Client StateServer State
Client에서 소유하며 온전히 제어가능함Client에서 제어하거나 소유되지 않은 원격의 공간에서 관리되고 유지됨
초기값 설정이나 조작에 제약사항 없음Fetching/Updatind에 비동기 API가 필요함
다른 사람들과 공유되지 않으며 Client 내에서 UI/UX 흐름이나 사용자 인터렉션에 따라 변할 수 있음다른 사람들과 공유되는 것으로 사용자가 모르는 사이에 변경될 수 있음
항상 Client 내에서 최신 상태로 관리됨신경 쓰지 않는다면 잠재적으로 "out of date"가 될 가능성을 지님

Onwership이 Client에

Onwership이 Server에

react-query

💡 fetching, caching, 서버 데이터와의 동기화를 지원해주는 라이브러리

개요

  • react-query는 리액트 애플리케이션에서 서버 상태 가져오기, 캐싱, 동기화 및 업데이트를 보다 쉽게 다룰 수 있도록 도와주는 라이브러리이다. 클라이언트 상태와 서버 상태를 명확히 구분하기 위해 만들어졌다.
  • react-query에서는 기존 상태 관리 라이브러리인 redux, mobX가 클라이언트 상태 작업에 적합하지만, 비동기 또는 서버 상태 작업에는 그다지 좋지 않다고 언급한다.
  • 클라이언트 상태(Client State)와 서버 상태(Server State)는 완전히 다른 개념이며, 클라이언트 상태는 각각의 input 값으로 예를 들 수 있고, 서버 상태는 데이터베이스에 저장되어 있는 데이터로 예를 들 수 있다.

기능

  • 캐싱
  • 동일한 데이터에 대한 중복 요청을 단일 요청으로 통합
  • 백그라운드에서 오래된 데이터 업데이트
  • 데이터가 얼마나 오래되었는지 알 수 있다.
  • 데이터 업데이트를 가능한 빠르게 반영
  • 페이지네이션 및 데이터 지연 로드와 같은 성능 최적화
  • 서버 상태의 메모리 및 가비지 수집 관리
  • 구조 공유를 사용하여 쿼리 결과를 메모화

React Query 기본 설정

// v4
import { QueryClient } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: Infinity,
      // ...
    },
  },
});
  • QueryClient를 사용하여 캐시와 상호 작용할 수 있다.
  • QueryClient에서 모든 query 또는 mutation에 기본 옵션을 추가할 수 있으며, 종류가 상당하기 때문에 공식 사이트를 참고해보자.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({ /* options */});

function App() {
  return (
   <QueryClientProvider client={queryClient}>
      <div>Text</div>
   </QueryClientProvider>;
  );
}
  • react-query를 사용하기 위해서는 QueryClientProvider를 최상단에서 감싸주고 QueryClient 인스턴스를 client props로 넣어 애플리케이션에 연결시켜야 한다.
  • 위 예시에서 App.js에 QueryClientProvider로 컴포넌트를 감싸고, client props에다 queryClient를 연결함으로써, 이 context는 앱에서 비동기 요청을 알아서 처리하는 background 계층이 된다.

Devtools

※ Devtools URL: react-query devtools

  • react-query는 전용 devtools를 제공한다.
  • devtools를 사용하면 React Query의 모든 내부 동작을 시각화하는 데 도움이 되며 문제가 발생하면 디버깅 시간을 절약할 수 있다.
  • devtools는 기본값으로 process.env.NODE_ENV === 'development' 인 경우에만 실행된다, 즉 일반적으로 개발환경에서만 작동하므로 설정되어있으므로, 프로젝트 배포 시에 Devtools 삽입코드를 제거해줄 필요가 없다.
// v3
import { ReactQueryDevtools } from "react-query/devtools";

<AppContext.Provider value={user}>
  <QueryClientProvider client={queryClient}>
    // ...
    <ReactQueryDevtools initialIsOpen={false} />
  </QueryClientProvider>
</AppContext.Provider>;

options

  • initialIsOpen (Boolean)
    • true이면 개발 도구가 기본적으로 열려 있도록 설정할 수 있다.
  • position?: ("top-left" | "top-right" | "bottom-left" | "bottom-right")
    • 기본값: bottom-left
    • devtools 패널을 열고 닫기 위한 로고 위치
  • 일반적으로 initialIsOpen, position을 자주 사용하지만, panelProps, closeButtonProps, toggleButtonProps와 같은 옵션들도 존재한다.

v4

v4부터는 devtools를 위한 별도의 패키지 설치가 필요하다.

$ npm i @tanstack/react-query-devtools
# or
$ pnpm add @tanstack/react-query-devtools
# or
$ yarn add @tanstack/react-query-devtools
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* The rest of your application */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

캐싱 라이프 사이클

  • React-Query 캐시 라이프 사이클
* Query Instances with and without cache data(캐시 데이터가 있거나 없는 쿼리 인스턴스)
* Background Refetching(백그라운드 리패칭)
* Inactive Queries(비활성 쿼리)
* Garbage Collection(가비지 컬렉션)
  • cacheTime의 기본값 5분, staleTime 기본값 0초를 가정

순서

  1. example 라는 queryKey를 가진 example 쿼리 인스턴스가 mount됨
  2. 네트워크에서 데이터 fetch하고, 불러온 데이터는 example 라는 queryKey로 캐싱함
  3. 이 데이터는 fresh상태에서 staleTime(기본값 0) 이후 stale 상태로 변경됨
  4. example 쿼리 인스턴스가 unmount됨
  5. 캐시는 cacheTime(기본값 5min) 만큼 유지되다가 가비지 콜렉터(GC)로 수집됨
  6. 만일, cacheTime이 지나기 전이고, example 쿼리 인스턴스 fresh한 상태라면 새롭게 mount되면 캐시 데이터를 보여준다.

useQuery

useQuery 기본 문법

// 사용법
const { data, isLoading, ... } =  useQuery(queryKey, queryFn, {
  ...options
});
or
// 사용법
const result = useQuery({
  queryKey,
  queryFn,
  ...options
});
// 실제 사용법
const KeywordFetch = async () => {
    return await httpClient.get<resKeywordDto>(
        SearchControllerPath.keywordGet
    );
};

const useKeyword = () => {
    const { data } = useQuery(['keyword'], keywordFetch, {
        staleTime: 1000 * 60 * 6,
        cacheTime: 1000 * 60 * 10
    });

    const keyword = useMemo(() => data?.data, [data]);

    return { keyword };
};
  • useQuery는 기본적으로 3개의 인자를 받는다. 첫 번째 인자가 queryKey(필수), 두 번째 인자가 queryFn(필수), 세 번째 인자가 options(optional)이다.

queryKey

  • v3까지는 queryKey문자열 또는 배열 모두 지정할 수 있는데, v4부터는 무조건 배열로 지정해야 한다.
  • useQuery는 첫 번째 인자인 queryKey를 기반으로 데이터 캐싱을 관리한다.

queryFn

  • useQuery의 두 번째 인자인 queryFn는 Promise를 반환하는 함수를 넣어야한다.
  • 참고로, queryKey의 예제와 queryFn 예제가 약간 차이점이 있다.
    • queryKey 예제는 2번째 queryFn에 getSuperHero 함수를 바로 넘겨주고, getSuperHero에서 매개 변수로 객체를 받아와 해당 객체의 queryKey를 활용하고 있다.
    • queryFn 예제는 그냥 2번째 queryFn에 화살표 함수를 사용하고, getSuperHero의 인자로 heroId를 넘겨주고 있다.
    • 해당 두 가지 방법은 모두 알아야되고, 결과는 동일하다.

options
옵션들을 알고싶다면 useQuery 공식 문서를 통해 확인해보자.

useQuery 주요 리턴 데이터

const { status, isLoading, isError, error, data, isFetching, ... } = useQuery(
  ["search", keyword],
  () => keywordFetch(keyword)
);
  • status: 쿼리 요청 함수의 상태를 표현하는 status는 4가지의 값이 존재한다.(문자열 형태)
    • idle: 쿼리 데이터가 없고 비었을 때, { enabled: false } 상태로 쿼리가 호출되면 이 상태로 시작된다.
    • loading: 말 그대로 아직 캐시된 데이터가 없고 로딩중일 때 상태
    • error: 요청 에러 발생했을 때 상태
    • success: 요청 성공했을 때 상태
  • data: 쿼리 함수가 리턴한 Promise에서 resolved된 데이터
  • isLoading: 캐싱 된 데이터가 없을 때 즉, 처음 실행된 쿼리 일 때 로딩 여부에 따라 true/false로 반환된다.
    • 이는 캐싱 된 데이터가 있다면 로딩 여부에 상관없이 false를 반환한다.
  • isFetching: 캐싱 된 데이터가 있더라도 쿼리가 실행되면 로딩 여부에 따라 true/false로 반환된다.
    • 이는 캐싱 된 데이터가 있더라도 쿼리 로딩 여부에 따라 true/false를 반환한다.
  • error: 쿼리 함수에 오류가 발생한 경우, 쿼리에 대한 오류 객체
  • isError: 에러가 발생한 경우 true
  • 그 외 반환 데이터들을 자세히 알고 싶으면 useQuery 공식 사이트 문서 참고

useQuery 주요 옵션

staleTime과 cacheTime

  • stale은 용어 뜻대로 썩은 이라는 의미이다. 즉, 최신 상태가 아니라는 의미이다.
  • fresh는 뜻 그대로 신선한 이라는 의미이다. 즉, 최신 상태라는 의미이다.
const { data } = useQuery(['keyword'], keywordFetch, {
  staleTime: 1000 * 60 * 6,	// 6분
  cacheTime: 1000 * 60 * 10	// 10분
});
  • staleTime: (number | Infinity)
    • staleTime은 데이터가 fresh에서 stale 상태로 변경되는 데 걸리는 시간, 만약 staleTime이 3000이면 fresh상태에서 3초 뒤에 stale로 변환
    • fresh 상태일 때는 쿼리 인스턴스가 새롭게 mount 되어도 네트워크 요청(fetch)이 일어나지 않는다.
    • 데이터가 한번 fetch 되고 나서 staleTime이 지나지 않았다면(fresh상태) unmount 후 다시 mount 되어도 fetch가 일어나지 않는다.
    • staleTime의 기본값은 0이기 때문에 일반적으로 fetch 후에 바로 stale이 된다.
  • cacheTime: (number | Infinity)
    • 데이터가 inactive 상태일 때 캐싱 된 상태로 남아있는 시간
    • 쿼리 인스턴스가 unmount 되면 데이터는 inactive 상태로 변경되며, 캐시는 cacheTime만큼 유지된다.
    • cacheTime이 지나면 가비지 콜렉터로 수집된다.
    • cacheTime이 지나기 전에 쿼리 인스턴스가 다시 mount 되면, 데이터를 fetch하는 동안 캐시 데이터를 보여준다.
    • cacheTime은 staleTime과 관계없이, 무조건 inactive 된 시점을 기준으로 캐시 데이터 삭제를 결정한다.
    • cacheTime의 기본값은 5분이다.
  • 여기서 주의할 점은 staleTime과 cacheTime의 기본값은 각각 0분과 5분이다. 따라서 staleTime에 어떠한 설정도 하지 않으면 해당 쿼리를 사용하는 컴포넌트(Observer)가 mount됐을 때 매번 다시 API를 요청할 것이다.
  • staleTime을 cacheTime보다 길게 설정했다고 가정하면, staleTime만큼의 캐싱을 기대했을 때 원하는 결과를 얻지 못할 것이다. 즉, 두 개의 옵션을 적절하게 설정해줘야 한다.

refetchOnMount

const { data } = useQuery(['popularKeyword'], PopularKeywordFetch, {
  refetchOnMount: 'always',
});
  • refetchOnMount (boolean | "always")
  • refetchOnMount는 데이터가 stale 상태일 경우, mount마다 refetch를 실행하는 옵션이다. 기본값은 true이다.
  • always 로 설정하면 마운트 시마다 매번 refetch를 실행한다.
  • false로 설정하면 최초 fetch 이후에는 refetch하지 않는다.

추가적인 옵션은 useQuery v4 공식 문서 참고

Infinite Queries

  • Infinite Queries(무한 쿼리)는 무한 스크롤이나 load more(더 보기)과 같이 특정 조건에서 데이터를 추가적으로 받아오는 기능을 구현할 때 사용하면 유용하다.
  • react-query는 이러한 무한 쿼리를 지원하기 위해 useQuery의 유용한 버전인 useInfiniteQuery을 지원한다.
import { useInfiniteQuery } from "@tanstack/react-query";

const KeywordFetch = async () => {
    return await httpClient.get<resKeywordDto>(
        SearchControllerPath.keywordGet
    );
};

const InfiniteQueries = () => {
  const { data, hasNextPage, isFetching, isFetchingNextPage, fetchNextPage } =
    useInfiniteQuery(["keyword"], KeywordFetch, {
      getNextPageParam: (lastPage, allPages) => {
        return allPages.length < 4 && allPages.length + 1;
      },
    });

  return (
    <div>
      {data?.pages.map((group, idx) => ({
        /* ... */
      }))}
      <div>
        <button disabled={!hasNextPage} onClick={() => fetchNextPage()}>
          LoadMore
        </button>
      </div>
      <div>{isFetching && !isFetchingNextPage ? "Fetching..." : null}</div>
    </div>
  );
};

useMutation

  • react-query에서 기본적으로 서버에서 데이터를 Get 할 때는 useQuery를 사용한다.
  • 만약 서버의 data를 post, patch, put, delete와 같이 수정하고자 한다면 이때는 useMutation을 이용한다.
  • 요약하자면 R(read)는 useQuery, CUD(Create, Update, Delete)는 useMutation을 사용한다.
const CreateTodo = () => {
  const mutation = useMutation(createTodo, {
    onMutate() {
      /* ... */
    },
    onSuccess(data) {
      console.log(data);
    },
    onError(err) {
      console.log(err);
    },
    onSettled() {
      /* ... */
    },
  });

  const onCreateTodo = (e) => {
    e.preventDefault();
    mutation.mutate({ title });
  };

  return <>...</>;
};
  • useMutation의 반환 값인 mutation 객체의 mutate 메서드를 이용해서 요청 함수를 호출할 수 있다.
  • mutate는 onSuccess, onError 메서드를 통해 성공 했을 시, 실패 했을 시 response 데이터를 핸들링할 수 있다.
  • onMutate는 mutation 함수가 실행되기 전에 실행되고, mutation 함수가 받을 동일한 변수가 전달된다.
  • onSettled는 try...catch...finally 구문의 finally처럼 요청이 성공하든 에러가 발생되든 상관없이 마지막에 실행된다.
const mutation = useMutation(addTodo);

try {
  const todo = await mutation.mutateAsync(todo);
  console.log(todo);
} catch (error) {
  console.error(error);
} finally {
  console.log("done");
}
  • 만약, useMutation을 사용할 때 promise 형태의 response가 필요한 경우라면 mutateAsync를 사용해서 얻어올 수 있다.

💡 mutate와 mutateAsync는 무엇을 사용하는게 좋을까?

  • 대부분의 경우 우리는 mutate를 사용하는 것이 유리하다. 왜냐하면 mutate는 콜백(onSuccess, onError)를 통해 data와 error에 접근할 수 있기 때문에 우리가 특별히 핸들링 해 줄 필요가 없다.
  • 하지만 mutateAsync는 Promise를 직접 다루기 때문에 이런 에러 핸들링 같은 부분을 직접 다뤄야한다.
    • 만약 이를 다루지 않으면 unhandled promise rejection 에러가 발생 할 수 있다.
profile
기록을 남기는 공간.

0개의 댓글