React Query를 알아보자

Seonup·2023년 11월 11일

본 시리즈는 정재남님의 풀스택 리액트 라이브코딩 - 간단한 쇼핑몰 만들기 강의 내용을 기반으로, 추가적인 학습을 통해 습득한 지식 또는 강의 코드를 다른 방법으로 구현한 경험을 작성하고 있습니다. 강의 코드(GitHub)를 확인하세요.

React Query

참고: React Query as a State Manager - TkDodo's blog

  • 캐시를 이용하여 서버 상태를 쉽게 관리할 수 있도록 도와주는 React 라이브러리
  • 비동기 상태 관리자로, 데이터 뿐만 아니라 Promise 형태의 비동기 상태를 관리할 수 있다.
  • React Query는 데이터를 refetch 해오는 전략 지표를 제공한다. (refetchOnMount, refetchOnWindowFocus, refetchOnReconnect 등)
  • 자동으로 refetch 시점을 지정하는 것 외에 queryClient.invalidateQueries를 사용하면 수동으로 데이터를 무효화 할 수 있다.

React Query 이전의 서버 데이터 관리 방식

  1. 애플리케이션의 마운트 시점에 data를 fetch하고 전역 상태로 저장하여 모든 컴포넌트가 data를 참조할 수 있도록 한다.
    • 이 방법은 fetch된 데이터를 자주 업데이트 하기 어렵다.
    • 전역 상태가 너무 많은 것을 관리하고 있다는 단점이 있다.
  2. data가 필요한 컴포넌트의 마운트 시점에 data를 fetch하고 지역 상태로 관리한다.
    • 컴포넌트가 자주 마운트될 경우 잦은 통신이 발생된다는 단점이 있다.

React Query의 내부 동작

참고: Inside React Query 한글 번역

image

QueryClient

  • 애플리케이션 시작시 QueryClient의 인스턴스를 생성하여 QueryClientProvider로 컴포넌트에 배부하면 React Query 라이브러리를 사용할 수 있다.

  • QueryClient로 생성된 인스턴스는 QueryCache, MutationCache의 컨테이너로, querymutation을 조작하는 메서드와 캐시 작업이 가능하다.

    • 캐시(cache): 데이터를 미리 복사해놓는 임시 장소로, 캐시에 접근하는 시간에 비해 원본 데이터에 접근하는 시간이 오래 걸릴 경우 시간을 절약하기 위해 자주 사용하는 방법이다.

    QueryCache

  • QueryCache는 안정적이고 직렬화된 queryKeys(queryKeyHash) 버전의 key와 Query 클래스의 인스턴스이자 메모리 내 객체(in-memory object)인 value로 이뤄져있다.

    • React Query는 기본적으로 메모리에만 데이터를 저장하고 다른 곳에는 저장하지 않기 때문에 브라우저를 새로고침하면 캐시가 사라진다. 이를 원치 않으면 다른 외부 저장소에 저장하는 persistQueryClient를 이용할 수 있다.
    • 캐시에는 쿼리들이 있는데 이 쿼리에서 대부분의 로직들이 실행된다. 여기에는 쿼리에 대한 모든 정보(데이터, 상태 필드, 마지막 fetching이 발생되었을 때 등의 메타 정보)와 쿼리 함수를 실행하고 재시도, 취소, 중복 제거를 하는 로직도 포함된다.

Query

  • QueryObserver를 통해 누가 쿼리 데이터를 구독하고 있는지 알고, 해당 관찰자에게 모든 변경사항을 알릴 수 있다.
    • ObserveruseQuery를 호출할 때 생성되며 useQueryqueryKey로 전달된 단 하나의 쿼리만을 구독한다. 즉, ObserverQueryCache에 있는 query를 구독한다.
      • ObserverQuery 업데이트를 컴포넌트에게 알려야 하는지 결정한다.
      • Observer는 컴포넌트가 사용 중인 query의 속성들을 알고 있기 때문에 관련 없는 변경 사항을 알릴 필요가 없다. 예를 들어, 데이터 필드만 사용하는 경우 백그라운드 refetch에서 isFetching이 변경될 때 컴포넌트를 다시 렌더링할 필요가 없다. 이렇듯 Observer는 많은 작업을 수행하며, 대부분의 최적화가 이뤄지는 곳이기도 하다.
    • Observer가 없는 쿼리를 비활성 쿼리라고 한다. 이 쿼리는 여전히 캐시에 있지만 컴포넌트에서 사용되고 있지 않는 것을 말한다. React Query 개발 도구에서는 쿼리를 구독하고 있는 Observer의 개수를 표기하고, 비활성 쿼리의 경우 회색으로 표시해두었다.

API Reference

QueryClient

  • react-query에서 제공하는 api로 캐시와 상호 작용하는 데 사용할 수 있으며, react query 라이브러리를 사용할 수 있는 진입점으로 볼 수 있다.
  • QueryCacheMutationCache의 컨테이너로, 모든 querymutation에 대해 설정할 수 있는 몇 가지 기본값을 소유하고 있으며 캐시 작업을 위한 편리한 방법을 제공한다. (대부분의 경우 cache에 직접 접근하지 않고 QueryClient를 통해 접근한다.)
  • query를 fetch 받거나 캐시하고 업데이트 하는 등 다양한 메서드를 가진 인스턴스를 반환하는 클래스로 만들어져 있어 new 연산자와 함께 호출할 수 있다.
  • QueryClientProvider를 이용하면 React Context를 이용하여 애플리케이션에 QueryClient를 배부할 수 있다.

options

QueryClient를 호출할 때 인수에 객체를 전달하여 옵션값을 설정할 수 있다. (queryCache, mutationCache, defaultOptions)

{ 
  queryCache: {
    queries: {
      /**
       * 기본값: 0
       * 얼마나 지나야 데이터를 stale하다고 판단할 지 결정하는 옵션으로, 지정한 시간 전까지는 stale한 데이터가 아니다.
       * stale data: 신선하지 않은, 즉 업데이트가 필요한 데이터를 말한다. === 캐시가 만료된 데이터
       * `query`가 최신 상태인 데이터는 항상 캐시에서만 읽히며 네트워크 요청이 발생하지 않는다.
       * stale 데이터 또한 캐시에서 가지오지만, 특정조건에서 refetch가 일어날 수 있다.
       */
      staleTime: '데이터가 캐시에 저장된 이후 다음 요청을 보낼 때까지 기다리는 시간(밀리초)',
      /**
       * 기본값: Infinity
       * 비활성 쿼리가 캐시에서 제거될 때까지의 시간이다.
       * `query`는 등록된 관찰자(`Observer`)가 없을 경우 데이터는 즉시 비활성 상태로 전환된다. 이때 해당 쿼리를 사용하는 모든 구성 요소는 언마운트(unmounted)된다.
       * `cacheTime`이 지나면 `query`는 가비지 컬렉터에 의해 삭제된다.
       */
      cacheTime: '캐시에 저장된 데이터의 유효 시간(밀리초)',
      /** 기본값: false */
      refetchInterval: '주기적으로 데이터를 다시 가져오는 시간(밀리초)',
      /** 기본값: false */
      refetchIntervalInBackground: '창이 비활성화되었을 때에도 refetchInterval을 계속 실행할지 여부',
      /** 기본값: true */
      refetchOnWindowFocus: '창이 활성화되었을 때에도 refetchInterval을 계속 실행할지 여부',
      /** 기본값: true */
      refetchOnMount: '컴포넌트가 처음 마운트될 때마다 데이터를 다시 가져올지 여부',
      /** 기본값: true */
      refetchOnReconnect: '인터넷 연결이 다시 활성화될 때마다 데이터를 다시 가져올지 여부',
      /** 기본값: 3 */
      retry: '요청이 실패할 경우 최대 재시도 횟수',
      /** 기본값: (attempt) => Math.min(1000 \* 2 \*\*attempt, 30000)) */
      retryDelay: '재시도 간격을 계산하는 함수',
      /** 기본값: true */
      retryOnMount: '컴포넌트가 처음 마운트될 때마다 요청을 다시 시도할지 여부',
      /** 기본값: true */
      retryOnWindowFocus: '창이 활성화되었을 때에도 요청을 다시 시도할지 여부',
      /** 기본값: false */
      suspense: '컴포넌트가 데이터를 가져올 때까지 대기하는 대신, Suspense를 사용하여 로딩 상태를 처리할지 여부',
      /** 기본값: false */
      useErrorBoundary: 'ErrorBoundary를 사용하여 요청이 실패했을 때 에러를 처리할지 여부',
      /** 기본값: undefined */
      queryFnParamsFilter: '쿼리 함수에 전달되는 인수를 필터링하는 함수',
    },
  },
  mutationCache: {
    mutations: {
      /** 기본값: {} */
      mutateOptions: 'mutate() 함수에 전달되는 옵션',
      /** 기본값: false */
      throwOnError: '서버 오류 발생 시 예외를 던질지 여부',
      /** 기본값: false */
      useErrorBoundary: 'ErrorBoundary를 사용하여 요청이 실패했을 때 에러를 처리할지 여부',
    },
  },
  defaultOptions: {
    /** queryKey에 대한 기본 옵션이다. */
    queries: {
      /** 기본값: 0 */
      staleTime : '데이터를 갱신하기 전에 만료되어야 하는 시간 (밀리초 단위)을 지정한다',
      /** 기본값: 0 */
      cacheTime: '데이터를 캐시에 저장할 시간 (밀리초 단위)을 지정한다',
      /** 기본값: true */
      retry: '서버에서 오류가 발생하면 자동으로 재시도 여부를 지정한다',
      /** 기본값: attempt => Math.min(attempt _ 1000, 30 _ 1000) */
      retryDelay: '서버에서 오류가 발생한 후 재시도 간격을 지정한다',
      /** 기본값: true */
      refetchOnWindowFocus: '윈도우 포커스가 되면 새로고침 여부를 지정한다',
      /**
       * 기본값: false
       * 값이 false이면 주기적으로 새로고침하지 않는다.
       * number 타입의 값이면 해당 값(밀리초)마다 주기적으로 새로고침 한다.
       */
      refetchInterval: '주기적으로 새로고침 할지 여부를 지정한다', 
      /**
       * 기본값: params => params
       * 이를 사용하면 쿼리 함수에 필요한 매개 변수만 전달할 수 있다
       */
      queryFnParamsFilter: 'queryFn에 전달될 매개 변수를 필터링하는 함수를 지정한다',
    },
    /** mutation에 대한 기본 옵션이다. */
    mutations: {
      /** 기본값: true */
      retry: '서버에서 오류가 발생하면 자동으로 재시도 여부를 지정한다',
      /** 기본값: attempt => Math.min(attempt _ 1000, 30 _ 1000) */
      retryDelay: '서버에서 오류가 발생한 후 재시도 간격을 지정한다',
      /** 기본값: error => console.error(error) */
      onError: '오류가 발생했을 때 호출할 함수를 지정한다',
    },
  },
}

method

getQueryData()

const data = queryClient.getQueryData(queryKey);
  • 기존 쿼리 데이터를 가져오는 데 사용할 수 있는 동기 함수로, 쿼리가 존재하지 않으면 undefined를 반환한다.
  • 한번에 여러 쿼리 데이터를 가져오려면 getQueriesData()를 사용해야 한다.
  • 옵션
    • filters?: QueryFilters: Query filter를 허용하는 프로퍼티
  • 반환값: data: TQueryFnData | undefined

setQueryData()

queryClient.setQueryData(queryKey, updater);
  • setQueryData()는 비동기로 동작하는 fetchQuery()와 달리 쿼리의 캐시된 데이터를 즉시 업데이트하는데 사용할 수 있는 동기 함수로, 쿼리가 없으면 생성된다.
  • 기본 cacheTime 5분 동안 Query Hook에서 쿼리를 사용하지 않으면 쿼리가 가비지 수집된다.
  • 한번에 여러 쿼리를 업데이트하고 Query key를 부분적으로 일치시키려면 setQueriesData()를 사용해야 한다.
  • updaterundefined일 경우 쿼리 데이터는 업데이트되지 않는다.
  • setQueryData() 내에서 onSuccess는 무한루프가 발생할 수 있기 때문에 호출할 수 없다. (v4 마이그레이션)
  • setQueryData()는 순수해야 한다. 즉, 내부에서 getQueryData()를 호출하여 즉각적으로 값을 변경시키면 안된다.
  • 옵션
    • queryKey: QueryKey: Query key
    • updater: TQueryFnData | undefined | ((oldData: TQueryFnData | undefined) => TQueryFnData | undefined): updater로 함수가 아닌 값이 전달되면 해당 값으로 데이터가 업데이트되고, 함수가 전달되면 이전 데이터 값을 수신하고 새로운 값을 반환한다.

cancelQueries()

  • 특정 querykey에 해당하는 다른 요청을 무시할 수 있도록 하는 api
  • Optimistic Update에서 데이터 refetch를 취소하는데 유용하게 사용된다.

QueryClientProvider

  • <QueryClientProvider>는 QueryClient 컴포넌트 provider로, 하위 클라이언트에게 QueryClient 컴포넌트를 JSX로 제공할 수 있다.
    • client (필수): 제공할 QueryClient의 인스턴스
    • contextSharing (기본값: false): context를 공유할 것인지를 선택하는 옵션

useQuery

  • useQuery는 react-query에서 제공하는 api로, query를 서버로부터 GET 받을 때 사용한다.
  • useQuery는 비동기로 동작한다.
    • useQuery는 렌더링 시점에 데이터를 가져오는 비동기 함수이다. 따라서 첫 렌더링에는 데이터를 가져오지 못하고, undefined를 반환할 수 있다.
  • useQuery는 첫 렌더링 때 데이터를 캐시하고, 이후에는 캐시된 데이터를 사용한다. 첫 렌더링 이후에 useQuery의 비동기 함수가 실행되고 데이터를 새롭게 캐시했기 때문에 리렌더링이 발생되며, data에는 캐시된 데이터에서 값을 읽어온다.
  • 쿼리는 자동으로 수행된다.
    • 종속성을 정의하지만, React Query는 즉각적인 쿼리 실행이나 업데이트가 필요하다고 판단되는 경우 자동으로 background update를 수행한다.
    • 백엔드의 실제 데이터와 화면에 표시되는 내용을 동기화 하는 것에 적합하다.
  • React Query는 동시에 발생하는 useQuery 요청의 중복을 제거한다. 따라서 동일한 QueryClientProvider 내부에서 동일한 렌더 주기에 호출되는 동일한 data fetching은 무시되어 한번의 네트워크 요청만 일어난다.
  • queryKey를 고유하게 식별하기 때문에 동일한 QueryClientProvider 내부에서 useQuery()를 사용하면 어떤 컴포넌트라도 동일한 data를 fetch 받을 수 있다.
    • 즉, 컴포넌트간의 상태를 공유할 수 있다.
    • 매번 useQuery() 하는 data fetch 함수에 접근하는 것보다 custom hooks를 만들어서 사용하는 것이 효율적이다.

작성 방법

useQuery(queryKey, queryFunction): UseQueryResult;
  • queryKey: (string, number, object)[]: query를 관리하는데 사용되는 unique key
  • queryFunction: api 호출을 하는 promise 함수

useMutation

  • useMutationmutation 상태를 추적하는 API로, 비동기로 동작한다.
  • loading, error, status field를 제공하여 사용자에게 무슨 일이 일어나고 있는지 제공한다.
  • useQuery의 콜백과 동일하게 onSuccess, onError, onSettled를 사용할 수 있다.
  • useQuery와 달리 컴포넌트간의 상태를 공유하지 않으며, mutation(변형)이 자동으로 수행되지 않는다.
    • refetch되는 시점마다 mutation 데이터의 변경이 일어나면 안되기 때문 (ex, 브라우저 창에 초점을 맞출 때마다 새로운 todo가 생성됨)
    • mutation을 자동으로 수행하는 대신, 변형을 하고 싶을 때마다 호출할 수 있는 함수(mutate)를 제공한다.
  • useMutation가 반환하는 mutatemutateAsync는 비슷하지만 mutate는 반환값이 없고, mutateAsync는 Promise를 반환한다는 차이가 있다.
    • 대부분의 경우 mutate를 사용하고 mutation의 응답에 접근해야 하는 경우에만 mutateAsync를 사용하는 것이 좋다.
    • mutateAsync는 promise 객체를 반환하기 때문에 try/catchthen/catch 체이닝을 이용한 에러 핸들링이 가능하다.
  • useMutation의 콜백은 mutate의 콜백 함수보다 먼저 실행되기 때문에 useMutation의 콜백에 UI와 관련된 로직을 실행한다면 mutate의 콜백이 실행되기 전에 컴포넌트가 언마운트 될 수 있다. 따라서 useMutation의 콜백에는 쿼리 무효화 등 필수적이고 논리적인 작업을 수행하고, mutate의 콜백에는 리다이렉트나 토스트 알림 같은 UI 관련 작업을 수행하는 것이 좋다.
    • custom hook을 사용할 때에도 콜백의 역할 분리가 잘 되어있으면 custom hook의 재사용성을 높일 수 있다.

options

  • onMutate?: (variables: unknown, mutation: Mutation) => Promise<unknown> | unknown
    • mutation이 시작되기 전에 동기적으로 호출되는 함수로, 서버와의 통신 없이 이뤄진다.
    • onMutate에서 반환하는 값은 onSuccess, onError, onSettled의 매개변수 context로 전달된다.
  • onSuccess?: (data: unknown, variables: unknown, context: unknown, mutation: Mutation) => Promise<unknown> | unknown: 일부 mutate가 성공하면 호출되는 함수로, mutate 이후 서버에서 반환하는 값이 첫번째 매개변수인 data로 들어온다.
  • onError?: (error: unknown, variables: unknown, context: unknown, mutation: Mutation) => Promise<unknown> | unknown
    • 일부 mutate가 실패하면 호출되는 함수
    • Optimistic Update 구현시 onMutate에서 반환한 context를 이용하여 이전값으로 돌리는 롤백 로직을 구현할 수 있다.
  • onSettled?: (data: unknown | undefined, error: unknown | null, variables: unknown, context: unknown, mutation: Mutation) => Promise<unknown> | unknown: 일부 mutate가 settled, 즉 성공이든 실패든 결과를 반환하면 호출되는 함수

useMutationquery에 대해 변경한 내용을 반영하는 방법

mutation은 query와 직접적으로 연결되지 않는다. mutation이 query에 대해 변경한 내용을 반영하기 위해서는 추가 구현이 필요하다.

1. 무효화(Invalidation)

  • invalidateQueries()을 사용하여 기존의 데이터를 stale data로 변경하고 refetch 받는다.

  • invalidateQueries 호출 결과

    1. 기존의 쿼리를 stale data로 변경한다.
    2. 해당 쿼리가 useQuery를 통해 렌더링되거나 비슷한 Hooks를 사용하고 있다면 데이터를 refetching한다.
  • 클라이언트에서 사용자의 액션에 의해 어떤 데이터가 변경되면 서버 데이터를 동기화할 필요가 있는데, 이런 경우에 많이 사용한다.

  • 인수로 QueryFilters를 전달하여 query 요청의 조건을 지정할 수 있다.

    queryClient.invalidateQueries({
      queryKey: [QueryKeys.PRODUCTS],
      exact: false,
      refetchType: "all",
    });
    • exact?: boolean: 쿼리 키로 쿼리를 검색할 때, 포괄적인 검색을 원하면 false를 전달하고 정확한 쿼리 키를 검색하려면 true를 전달한다.
    • refetchType?: 'active' | 'inactive' | 'all' | 'none': 기본값은 active로, 어떤 타입의 쿼리를 다룰 것인지 지정할 수 있다.

2. 직접 업데이트

  • setQueryData()를 사용하면 쿼리 캐시를 직접 업데이트 할 수 있다.
  • setQueryData()를 통해 데이터를 직접 캐시에 넣으면 데이터가 서버에서 반환된 것처럼 동작하므로 해당 쿼리를 사용하는 모든 컴포넌트가 리렌더링된다.
  • 서버에서 데이터를 fetch 받아오고 싶지 않을 때 사용하는 방법으로, mutation이 서버가 필요한 데이터를 모두 반환할 때 사용한다.
  • 직접 업데이트는 서버의 데이터 구조가 변경되었을 위험이 있기 때문에 비교적 안전하지 않은 접근 방식일 수 있다.

3. 낙관적 업데이트(Optimistic Update)

  • Optimistic Update는 사용자의 액션에 의해 특정 서버 데이터의 변경이 발생되었을 때, 실제 서버로부터 성공 응답을 받기 전에 성공할 것이라는 낙관적인 마인드로 성공했을 때의 데이터를 미리 화면에 구현한다.
    • 비관적 업데이트(Pessimistic Update): 일반적으로 사용되는 서버 통신으로, 사용자의 액션으로 서버 요청이 발생되고 응답을 받으면 UI가 업데이트 되는 로직이다.
  • Optimistic Update는 보다 빠른 UI 업데이트로 사용자의 경험을 개선하고, 빠른 피드백을 제공할 수 있다는 장점이 있다.
  • 서버의 응답이 성공적이지 않을 경우 이전 상태로 돌아가기 때문에 업데이트 롤백 로직을 함께 구현해야 한다는 번거로움이 있다.
  • 서버에 데이터가 반영되기 전에 애플리케이션이 종료되거나 네트워크에 문제가 생긴다면 데이터 소실의 위험이 있으므로, 과도한 사용은 지양해야 한다.
  • 주로, 좋아요 버튼 같은 가벼운 서버 통신에 사용된다.
작성 방법
  • useMutationonMutate 콜백을 이용하여 Optimistic Update를 제공하고, onError, onSettled 콜백을 이용하여 롤백 로직을 구현할 수 있다.
  • Optimistic Update 구현 시 데이터 꼬임 방지: cancelQueries() 사용
    • React Query가 제공하는 refetchOnMount 옵션의 기본값은 true이기 때문에, React Query는 컴포넌트가 마운트될 때 데이터를 최신으로 업데이트 해주기 위해 refetch를 한다. 이때 refetch 시점은 정확하게 알 수 없기 때문에 타이밍이 꼬이면 optimistic update 데이터가 먼저 보이고, 나중에 응답된 refetch 데이터(예전 데이터)가 이를 override 되어 화면에는 예전 데이터가 그대로 뿌려지는 현상이 일어날 수 있다. 이를 막기 위해서는 cancelQueries()를 이용하여 refetch를 취소해야 한다.
  • Optimistic Update 구현 후 안정화 처리를 하는 방법
    • onMutate 내부에 구현한 로직은 변경 전 데이터를 기준으로 업데이트를 하고 UI에 출력한다. 이 작업은 서버와의 통신 없이 이뤄지기 때문에 서버의 최종 응답과 다른 결과를 보여줄 수 있어 안정화 처리가 필요하다.
    • 서버 데이터와의 동기화에 안정성을 주기 위해서는 onMutate 이후 onSuccess 콜백에 새로운 데이터로 캐시를 업데이트하는 작업이 필요하다.
    • onSuccess 콜백은 서버로부터 실제 응답을 받았을 때, 즉 Optimistic Update를 수행하고 난 뒤 서버와 동기화된 데이터를 사용하여 UI를 업데이트할 때 사용한다.
    • onSuccess 콜백을 사용하지 않고 onMutate만 작성했을 경우, 서버 응답이 실패한다면 UI와 실제 데이터가 동기화되지 않을 수 있다. 따라서 onSuccess 내부에 setQueryData()를 이용하여 새로운 데이터로 캐시를 업데이트 해주는 것이 좋다.

useInfiniteQuery

function useInfiniteQuery<
  TQueryFnData = unknown,
  TError = unknown,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey
>({
  queryKey,
  queryFn: ({ pageParam = 1 }) => fetchPage(pageParam),
  ...options,
  getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
}): UseInfiniteQueryResult<TData, TError>;
  • useInfiniteQuery는 해당 쿼리에 대한 데이터 수신(useQuery)을 파라미터 값만 변경하여 무한정 호출할 때 사용하는 메서드이다.
  • 수신된 데이터는 페이지 단위로 수신/관리하며, getNextPageParam, getPreviousPageParam으로 다음 수신에 필요한 파라미터 값을 전달할 수 있다.
profile
박선우

0개의 댓글