[React] React Query 정리

js43o·2023년 1월 12일
0
post-thumbnail

1) React Query란?

React Query는 리액트 환경에서 서버 상태 및 비동기 데이터를 관리하기 위한 라이브러리이다.

기존에 Redux를 통해 API 통신을 수행하거나 비동기 데이터를 관리할 때엔 여러 불편함이 있을 수밖에 없었다. 왜냐하면 Redux는 비동기 데이터를 다루기 보단 '전역 상태 관리'를 목적으로 개발된 라이브러리이기 때문이다.

Redux에서는 비동기 요청을 처리하기 위해 redux-thunkredux-saga와 같은 복잡한 문법의 추가 라이브러리를 필요로 했고, 데이터의 상태(loading, success, failure 등)에 따라 또 다시 수많은 보일러 플레이트 코드를 작성해야 했다.

하지만 React Query는 그와 달리 처음부터 비동기 데이터를 다루기 위해 개발된 라이브러리이므로 그런 면에서 훨씬 더 편리하고, 깔끔하고, 강력하다. 게다가 단순히 서버 상태를 가져오거나 변경하는 것을 넘어 데이터 캐싱, 지속적인 동기화 및 업데이트 등의 기능을 제공하기도 한다.


2) Queries

HTTP GET 요청과 같이 외부 데이터를 불러오고자 할 때는 useQuery hook을 통해 쿼리를 호출할 수 있다.

본격적인 쿼리 사용법을 배우기 전에, 다음 사항들을 먼저 알아두자.

  • useQuery를 사용해 가져온 쿼리 인스턴스는 기본적으로 캐시된 데이터를 'stale' 하다고 여김.
    • state == '만료됨', '더는 유효하지 않음'
  • stale한 쿼리는 다음과 같은 상황에서 자동으로 refetching됨.
    • 해당 쿼리의 새 인스턴스가 마운트 되었을 때
    • 윈도우 창이 다시 포커싱 되었을 때
    • 네트워크가 재연결 되었을 때 등...

useQuery(queryKey, queryFn, config)

  • queryKey: 내부적으로 쿼리를 관리하기 위한 unique하고 serializable한 값.
    • 문자열 하나여도 되고, 여러 값을 포함한 배열이어도 된다. 배열 안 원소들의 순서가 다르면 서로 다른 쿼리로 취급한다.
    • 서로 다른 두 컴포넌트에서 동일한 쿼리 키로 인스턴스를 생성하면 서로 공유하게 됨!
  • queryFn: Promise를 반환하는 비동기 함수. 외부 데이터를 직접 요청하는 코드가 포함된다. 요청 성공 시엔 응답된 값을 넣어주고, 에러 발생 시엔 throw문을 통해 반드시 에러를 던져줘야 한다.
    • fetch API는 HTTP 요청에 실패하더라도 axios와 달리 자동으로 에러를 던지지 않음에 주의할 것. (네트워크 오류 시에만 에러 발생)
    • { queryKey === [_key, { status, page }] } 인자를 통해 쿼리 키를 참조할 수도 있다.
  • config: 쿼리의 옵션을 지정한다. 자세한 사항은 밑에 따로 적어두었다.

참고로, 객체 형식으로도 인자를 전달할 수 있다.

useQuery({ queryKey: ['todo', 7], queryFn: fetchTodo, ...config })

useQuery로 불러온 쿼리 결과는 다음과 같이 이용할 수 있다.

const { isLoading, isError, data, error } = useQuery('todos', fetchTodoList)
// 또는 const { state, data, error } = ...도 가능
// state === 'loading' | 'error' | 'success'

if (isLoading) {
     return <span>Loading...</span>
   }
 
   if (isError) {
     return <span>Error: {error.message}</span>
   }
 
   return (
     <ul>
       // data에는 요청 성공 시의 응답 값이 들어있다
       {data.map(todo => (
         <li key={todo.id}>{todo.title}</li>
       ))}
     </ul>
   )
  • 쿼리가 최초로 실행될 때의 'loading' 상태가 아닌, 데이터 최신화를 위해 'refetching' 되고 있을 때의 상태를 알려주고 싶을 수도 있다. 그럴 땐 useQuery의 반환값으로 ifFetching 변수를 받아와서 쓰면 된다.
  • 개별 쿼리가 아닌 앱 전체에서 refetching을 감지하고 싶을 땐 useIsFetching hook을 이용하자.
  • queryClient.prefetchQuery(queryKey, queryFn)를 통해 특정 쿼리를 Prefetching 하는 것도 가능하다.

useQueries({ queryKey, queryFn, config }[])

  • 동적으로 여러 개의 쿼리를 실행해야 할 때는 useQuery를 직접 반복하여 쓰는 대신 useQueries를 이용할 수도 있다.
  • queryKeyqueryFn으로 이루어진 객체의 배열을 받아서, 쿼리 결과가 들어있는 배열을 반환한다.

useInfiniteQuery(queryKey, queryFn, config)

  • 무한 스크롤 등을 통해 지속적으로 쿼리를 여러 번 요청해야 하는 상황이라면 useInfiniteQuery hook을 사용할 수 있다.
  • 자세한 내용은 공식 문서 또는 밑의 링크를 참고하자. (설명이 잘 되어있는 다른 분의 게시글을 발견하여 첨부합니다)

https://jforj.tistory.com/246

2-1) useQuery의 주요 옵션(config)들

  • enable

이 옵션은 쿼리의 활성화 여부를 결정한다. 만약 이 값으로 false를 할당한다면 해당 쿼리의 status는 캐시된 데이터의 존재 여부에 따라 'success' 또는 'idle'로 고정되며, 자신의 refetch 메서드를 사용하지 않는 한 (자동으로든 남에 의해서든) refetching 되지 않는다.

Dependent query: 이전에 수행을 마친 쿼리에게 의존하는 쿼리를 뜻한다. enabled 값으로 이전 쿼리 결과를 넣어주면, 그 쿼리 결과가 도출될 때까지 해당 쿼리가 실행되지 않는 식이다.

  • retry

react-query는 기본적으로 쿼리가 실패했을 때 3번까지 해당 쿼리를 재시도하는데 이 항목을 통해 재시도 횟수를 임의로 지정할 수 있다. (false, true, 숫자 등)

  • keepPreviousData

쿼리 키를 바꿔서 쿼리를 요청할 시 이전 쿼리에 대한 결과를 현재 쿼리 결과를 완전히 받아올 때까지 유지시킬 수 있다.
이 옵션은 Pagination을 구현할 때 주로 쓰인다. 새 데이터를 fetching 하는 도중에도 이전 결과가 그대로 유지되므로 불필요한 화면 전환 없이 부드럽게 다음 페이지를 보여줄 수 있기 때문이다.
또한 useQuery의 반환값 중 하나인 isPreviousData를 통해 이전 쿼리에 대한 결과가 존재하는지 여부를 알 수 있다.

  • placeholderData

쿼리의 실제 데이터 fetching이 이뤄지기 전에 미리 보여줄 더미 데이터를 지정할 수 있다. 값 자체 또는 값을 반환하는 함수를 넣어주며, 함수 내부에서 queryClient를 통해 외부 쿼리의 데이터를 불러와 이용할 수도 있다.

  • initialData

placeholderData와 비슷하나, 넣어준 데이터에 대해 캐싱이 이루어진다. 즉, 더미 데이터가 아닌 실제 데이터의 첫 요청을 대신하고자 할 때 주로 사용한다.
staleTime 옵션을 함께 사용하여 초기 데이터가 stale 상태로 취급되기 전까지의 시간을 지정해줄 수 있다. (0이면 즉시 refetching 발동)

  • staleTime

해당 쿼리의 데이터가 stale로 취급되기까지의 시간을 지정한다. 기본값은 0이며 Infinity도 줄 수 있다.

  • suspense

해당 쿼리의 suspense 모드 활성화 여부를 지정한다. 이 값이 true이면 useErrorBoundary 옵션도 기본적으로 true가 된다.

const UserProfile = () => {
  const { data } = useQuery(queryKey, queryFn, { suspense: true });
  ...
}

const User = () => (
  <ErrorBoundary FallbackComponent={UserProfileFallback}>
    <Suspense fallback={<UserProfileLoading />}>
      <UserProfile />
    </Suspense>
  </ErrorBoundary>
);

이 옵션과 <Suspense>, <ErrorBoundary>를 이용하면 선언형 컴포넌트 방식으로 화면을 구성할 수 있다. 자세한 내용은 밑 게시글을 참고하자.

React Query와 함께 Concurrent UI Pattern을 도입하는 방법


3) Mutations

외부 데이터를 그대로 가져오기만 하는 query와 달리, 외부 데이터를 변경(create/update/delete)하거나 'Side Effect'를 주고 싶을 땐 useMutation hook을 이용한다.

Side Effect가 항상 나쁜 의미(부작용)로 쓰이는 것은 아니다.
단순히 어떤 주된 작업 후에 따르는 (의도적인) 부수적 효과를 뜻할 때 쓰이기도 한다.

useMutation(queryFn, config)

  • queryFn: 서버 요청을 보낼 비동기 함수를 정의한다.
  • config: 옵션 객체로, onMutate/onError/onSuccess/onSettled 메서드를 포함하여 mutation 과정에 따른 사이드 이펙트를 만들어 낼 수 있다.

useMutation 호출 후 반환된 객체 mutation의 비동기 메서드 mutate를 통해 쿼리를 수행할 수 있으며, status, error, data 등의 값을 사용할 수 있다는 점은 useQuery와 동일하다.

 function App() {
   // mutation 객체 생성
   const mutation = useMutation(newTodo => {
     return axios.post('/todos', newTodo)
   })
 
   return (
     <div>
       {mutation.isLoading ? (
         'Adding todo...'
       ) : (
         <>
           {mutation.isError ? (
             <div>An error occurred: {mutation.error.message}</div>
           ) : null}
 
           {mutation.isSuccess ? <div>Todo added!</div> : null}
 
           <button
             onClick={() => {
               // mutate 메서드를 통해 인자 전달
               mutation.mutate({ id: new Date(), title: 'Do Laundry' })
             }}
           >
             Create Todo
           </button>
         </>
       )}
     </div>
   )
 }
  • mutate 호출 시의 인자는 단일 값 또는 객체이며, useMutation에서 정의한 queryFn의 인자로 전달된다.
  • mutation.reset()을 통해 errordata를 초기화할 수 있다.
  • onMutate/onError/onSuccess/onSettled 메서드는 useMutation이 아닌 mutate의 인자로도 넣어줄 수 있다. (= component-specific side effect)

4) QueryClient

queryClient는 쿼리의 캐시와 상호작용하는 객체이며, 유용한 메서드들을 여럿 제공한다. 우리가 react-query를 통해 만든 모든 쿼리들과 연결되어 있는 객체라고 생각하면 될 것 같다. 밑의 예시처럼 쓸 수 있다.

 import { QueryClient } from 'react-query'
 
 const queryClient = new QueryClient({
   defaultOptions: {		// 옵션 기본값 지정
     queries: {
       staleTime: Infinity,
     },
   },
 })
 
 await queryClient.prefetchQuery('posts', fetchPosts)	// Prefetch 메서드

Query Invalidation

어떠한 이유로 쿼리의 데이터가 (stale로 지정되기 전에) 유효하지 않게 되었을 때, queryClientinvalidateQueries 메서드를 사용하여 모든 또는 특정 쿼리를 무효화(invalidate)시킬 수 있다.
이 경우 해당 쿼리는 stale로 취급받게 되며, 만약 컴포넌트에서 사용되고 있다면 새로운 데이터를 다시 받아오기 위해 refetching이 발동될 것이다.

queryClient.invalidateQueries(queryKey, option)

  • queryKey: 무효화시킬 쿼리의 키를 적는다. 해당 키가 포함되는 모든 쿼리를 무효화시키며, 생략 시 캐시의 모든 쿼리를 무효화시킨다.
  • option으로 { exact: true }를 포함하면 queryKey와 정확히 일치하는 쿼리만을 무효화시킨다.

queryKey 대신 조건을 직접 정의하여 쿼리를 선택할 수도 있다. 이 경우 predicate 메서드가 사용된다.

queryClient.invalidateQueries({
   predicate: query =>
  	 // querykey에 포함된 version이 10 이상인 것만 무효화
     query.queryKey[0] === 'todos' && query.queryKey[1]?.version >= 10,
 })
 
 // 무효화!
 const todoListQuery = useQuery(['todos', { version: 20 }], fetchTodoList)
 
 // 무효화!
 const todoListQuery = useQuery(['todos', { version: 10 }], fetchTodoList)
 
 // 무효화되지 않음
 const todoListQuery = useQuery(['todos', { version: 5 }], fetchTodoList)

그렇다면 정확히 언제 query invalidation이 필요할까?

만약 서버에 어떤 데이터가 존재하고, 우리가 useQuery로 해당 데이터를 불러와 컴포넌트를 통해 보여주고 있다고 하자.
사용자 쪽에서 useMutation을 호출하여 서버 데이터를 변경한다면, 기존에 보여주던 데이터 중 몇몇은 더이상 유효하지 않게 될 것이다.
이때 query invalidation을 적용하여 기존에 보여주던 쿼리들을 무효화하고 새로 데이터를 받아오도록 하면 데이터 일관성을 보장할 수 있다.

 import { useMutation, useQueryClient } from 'react-query'
 
 const queryClient = useQueryClient()
 
 // When this mutation succeeds, invalidate any queries with the `todos` or `reminders` query key
 const mutation = useMutation(addTodo, {
   onSuccess: () => {
     queryClient.invalidateQueries('todos')
     queryClient.invalidateQueries('reminders')
   },
 })

성공 여부와 상관 없이 mutation 실행 시마다 무효화를 하고 싶다면 onSuccess 대신 onSettled 메서드에 적어주자. (이 방법이 더 안정적인 것 같다)

queryClient.setQueryData(queryKey, data)

만약 서버 데이터를 업데이트 한 후 다시 refetching을 하지 않고도 데이터를 동기화하고 싶다면 mutation의 응답을 이용하면 된다.
mutation 요청이 성공했다면 onSuccess 메서드의 data 인수로 mutation의 결과가 전달된다. 이때 queryClientsetQueryData 메서드를 통해 쿼리 데이터 값을 직접 변경해준다.

 const mutation = useMutation(editTodo, {
   onSuccess: data => {
     queryClient.setQueryData(['todo', { id: 5 }], data)
   }
 })
 
 mutation.mutate({
   id: 5,
   name: 'Do the laundry',
 })
 
 // mutation 성공 후 쿼리 결과가 업데이트됨
 const { status, data, error } = useQuery(['todo', { id: 5 }], fetchTodoById)

queryClient.getQueryData(queryKey)

mutation이 중간에 실패하는 경우를 대비해 onMutate 내부에서 queryClient.getQueryData(queryKey)를 호출하여 기존 쿼리 값을 불러와 리턴한 후, onError에서 이를 다시 queryClient.setQueryData(queryKey, data)으로 되돌려주는 update rollback 방식을 구현할 수 있다.

queryClient.cancleQueires(queryKey)

쿼리 요청이 지나치게 오래 걸리는 경우, 수동으로 쿼리를 취소하기 위해 이 메서드를 사용할 수도 있다.
취소된 쿼리는 이전 상태로 되돌아간다.

unmounted 또는 unused 쿼리를 자동으로 취소하려면 queryFn의 인자 signal을 HTTP API에 옵션으로 넘겨주면 된다.


처음엔 기본적인 기능만 간단히 적어두려고 했는데, 공식 문서를 보다보니 중요해보이는 것들이 꽤 많아서 글이 좀 길어졌다.

지금까지는 나도 프로젝트를 할 때마다 Redux를 통해 비동기 데이터를 관리했었는데,
외부에서 불러온 상태와 내부에서 쓰는 상태가 잘 구분되지 않는다거나, 코드가 필요 이상으로 장황해지는 점 등등 여러 면에서 불편함을 느꼈었기에 React Query가 아주 매력적으로 느껴졌다. (심지어 위 단점을 다 해소하면서도 캐싱 등 추가적인 강력한 기능들이 너무 멋졌다)

어서 다음 프로젝트에 써보고 싶다!

Reference

React Query 공식 문서
카카오페이 프론트엔드 개발자들이 React Query를 선택한 이유

profile
공부용 블로그

0개의 댓글