상세 컨텐츠

본문 제목

[React-Query] 리액트로 비동기 다루기

Programming/React

by 쌩우 2021. 5. 24. 23:13

본문

react에서 비동기를 다루는 방법은 다양하다.

javascript 언어니까 당연히 Promise, async & await으로 처리가 가능하다.

redux를 사용하고 있다면, redux saga, thunk 등 다양한 미들웨어가 제공된다.

하지만 이런 것들로 서버의 상태를 관리하기란 여간 어려운 일이 아니다.

아래의 이유들로 앱과 서버 간의 관계 및 상태를 말할 수 있을 것이다.

  • 원격에 위치한 곳에 저장. 앱이 소유하거나 제어하지 않음.
  • 데이터 가져오기 및 업데이트를 위해서는 비동기 API가 필요.
  • 다른 사람들과 함께 사용해서, 나도 모르는 순간에 업데이트 될 수 있음.
  • 앱에서 사용하는 데이터가 OUTDATED 상태가 될 가능성 가짐.

기본적인 React Query 형태

함수형 컴포넌트 내에서 Hook 형태로 사용한다.

서버의 상태를 다른 곳에 저장하여 관리할 필요가 없다.

import { useQuery } from 'react-query';

function Example() {
  const { isLoading, error, data, isFetching } = useQuery('repoData', () =>
    fetch(
      'https://api.github.com/repos/tannerlinsley/react-query'
    ).then((res) => res.json())
  );

  if (isLoading) return 'Loading...';

  if (error) return 'An error has occurred: ' + error.message;

  return (
    <div>{JSON.stringify(data)}<div>
  );
}

따로 저장하지 않다보니, 서버에서 가공된 상태의 데이터를 바로 사용하는 경우에 적용하기 좋다.

또한 다른 멀리 떨어진 컴포넌트에게 데이터를 전달할 필요가 없는 경우에 적용하기 좋다.

만약, 서버 데이터를 직접 제어하는 것을 선호한다면 React Query는 도입하지 않는 것이 나을 수도 있다.

React Query의 장점 중 하나 "캐싱"

React Query의 Hook 중 하나인 useQuery 파라미터로 API 데이터의 만료 시간, 리프레싱 주기, 데이터를 캐시에서 유지할 기간, 브라우저 포커스 시 데이터 리프레시 여부, 성공/에러 콜백 등의 기능을 제어할 수 있다.

기능과 관련하여 최근에 구현한 기능 중 하나를 예로 들자면, "아이템 목록"이 가능할 것 같다.

- 목록에 설정한 필터와 같은 설정값은 서버에서 변경될 가능성이 낮기 때문에 만료 시간을 무한으로 설정해 추가 API 호출을 방지할 수 있음.

- 목록 만료 시간을 수 분으로 설정하여, 사용자가 페이지를 반복하여 이동하는 경우 API 반복 호출하는 것을 방지할 수 있음.

- 수 분의 만료 시간 내에, 목록에 아이템을 추가 생성하거나 수정할 경우는? -> 캐시를 강제로 무효화시키고 목록을 새로고침.

- 아이템 정보를 수정한 후 목록으로 돌아갔을 경우, 새로이 API 호출한 결과값으로 수정 정보가 반영된 아이템 목록을 보여줘야 함. 하지만 React Query로 API 호출 결과값이 아닌, 수정 시 캐시된 아이템 정보를 사용하여 목록 정보가 즉시 응답된 것으로 보이게 만들 수 있음.

React Query의 사용법

React Query를 통해 관리하는 쿼리 데이터(Query, useQuery가 반환하는 객체 속성값)는 4가지 상태를 가진다.

1. fresh : 새롭게 추가된 쿼리 인스턴스이며, 만료되지 않은 쿼리. 컴포넌트의 mount, update 시에 데이터를 재요청하지 않음.

2. fetching : 요청 상태인 쿼리.

3. stale : 데이터 패칭이 완료되어 만료된 쿼리. stale 상태의 같은 쿼리를 useQuery로 재호출하여 컴포넌트 마운트를 한다면 캐싱된 데이터가 반환됨.

4. inactive : 비활성 쿼리로써 사용하지 않음. 5분 뒤에 가비지 콜렉터가 캐시에서 제거함.

5. delete : 가비지 콜렉터에 의하여 캐시에서 제거된 쿼리.

Important Defaults

React Query API의 기본 설정에 대한 내용이다.

  • useQuery, useInfiniteQuery로 가져온 데이터는 stale 상태가 된다.
  • Stale 쿼리는 다음과 같은 경우에 백그라운드에서 자동으로 리패칭된다
    • 새 쿼리 인스턴스가 마운트 된 경우
    • 브라우저 윈도우가 다시 포커스 된 경우
    • 네트워크가 재연결 된 경우
    • refetchInterval 옵션이 있는 경우
  • 활성화 상태의 useQuery, useInfiniteQuery 인스턴스가 없는 쿼리 결과는 "inactive" 라벨이 붙고 다음 사용까지 남아있는다.
  • 백그라운드에서 3회 이상 실패한 쿼리는 에러로써 처리된다.

QueryClientProvider 설정

캐시를 관리하기 위하여 QueryClient 인스턴스를 사용한다.

이 때, 컴포넌트가 useQuery hook 안에서 QueryClient 인스턴스에 접근 가능하도록 만드는 QueryClientProvider를 컴포넌트 트리 상위에 추가해야 한다.

import { QueryClient, QueryClientProvider } from 'react-query'

const queryClient = new QueryClient() 

function App() {
  return (
      <QueryClientProvider client={queryClient}>
          <Component />
      </QueryClientProvider>
}

useQuery를 사용해 서버 데이터 가져오기

서버에서 데이터를 가져와 캐싱할 때 사용하는 기본적인 hook이다.

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

queryFunction에는 서버에 데이터를 요청하고 Promise 또는 에러를 반환하는 함수를 전달한다.

queryKey에는 문자열과 배열을 넣을 수 있다. queryKey의 유연성이 캐싱 처리를 도와주는 핵심 역할이다. queryKey의 조합에 따라 key가 다르면 캐싱도 별도 관리하기 때문이다.

자세한 내용은 이 곳에서 확인한다.

다음은 useQuery의 반환값 및 옵션에 대한 내용이다.

반환값

  • data : queryFunction이 반환한 Promise resolve 데이터
  • isLoading : 캐시된 데이터가 없는 상태에서 데이터를 요청중일 때 true
  • isFetching : 캐시 데이터 유무와 상관없이 데이터를 요청중일 때 true

옵션

  • cacheTime : 기본값은 5분으로, unused / inactive 캐시 데이터를 메모리에서 유지시킬 시간.
    • Infinity로 설정하면 쿼리 데이터가 캐시에서 제거되지 않는다.
  • staleTime : 기본값은 0으로, 쿼리 데이터가 fresh에서 stale로 전환되는데 걸리는 시간이다.
    • Infinity로 설정하면 쿼리 데이터는 직접 캐시를 무효화할 때까지 fresh 상태로 유지된다.
    • 캐시는 메모리에서 관리되므로 브라우저 새로고침 후에는 다시 가져온다.
  • onSuccess- queryFunction이 성공적으로 데이터를 가져왔을 때 호출되는 함수이다.
  • onError- queryFunction에서 오류가 발생했을 때 호출되는 함수이다.
  • onSettled- queryFunction이 성공, 실패한 경우 모두 실행되는 함수이다.
  • keepPreviousData- queryKey가 변경되어 새로운 데이터를 요청하는 동안에도 마지막data값을 유지한다.
    • 페이지네이션을 구현할 때 유용하다. 캐시되지 않은 페이지를 가져올 때 화면에서 viewing 컴포넌트가 사라지는 현상을 방지할 수 있다.
    • isPreviousData값으로 현재의 queryKey에 해당하는 값인지 확인할 수 있다.
  • initialData- 캐시된 데이터가 없을 때 표시할 초기값이다.placeholder와는 달리 캐싱이 된다. 브라우저 로컬 스토리지에 저장해 둔 값으로 데이터를 초기화할 때 사용할 수 있을 것이다.
  • refetchOnWindowFocus- 윈도우가 다시 포커스되었을 때 데이터를 호출할 것인지 여부이다. 기본값은true이다.

Parallel

일반적인 상황에서 여러 query가 선언되어 있는 경우라면, 병렬적으로 요청 및 처리된다.
덕분에 query 처리의 동시성이 극대화된다.

function App() {
    const profilesQuery = useQuery('profiles', fetchProfiles)
    const projectsQuery = useQuery('projects', fetchProjects)
    const groupsQuery = useQuery('groups', fetchGroups)

만약 여러 query들을 동시에 수행해야 하는데, 렌더링이 거듭되는 사이 사이에 계속해서 query가 수행되어야 한다면 query를 수행하는 것이 hook 규칙에 위배될 수도 있다.
그럴 때 쓰면 좋은 것이 useQueries이다

function App({users}) {
    const userQueries = useQueries(
        users.map(user => {
            return {
                queryKey: ['user', user.id],
                queryFn: () => fetchUserBZyId(user.id),
               }
           })
         )
}

useMutation으로 서버 데이터 업데이트

서버에서 데이터를 가져오는 것은 단순히 useQuery를 사용하면 될테지만, 서버의 데이터를 업데이트 하는 경우에는 동일한 방식을 사용하는 것이 적절치 않다. 데이터의 생성/수성/삭제 (CRUD) 시에는 "useMutation" hook을 사용하면 된다.

const mutation = useMutation(newTodo => axios.post('/todos', newTodo))

const handleSubmit = useCallback(
    (newTodo) => {
        mutation.mutate(newTodo)
    },
    [mutation],
)

useQuery의 옵션과 같이 콜백을 전달할 수 있다.
(onSuccess, onError, onSettled)
심지어 mutate 호출 시 실행할 onMutate 콜백도 사용할 수 있다.

만약 Redux를 사용한다면 request 성공에 대한 액션을 미들웨어에서 확인 후 추가 액션을 취할 것이다.
(Redux Saga라고 가정한다면, 특정 액션_SUCCESS 와 같은 형태의 액션말이다.)

useMutation을 사용한다면 onSuccess 콜백말고, mutateAsync 함수를 사용하는 것이 가독성에 좋을 것이다.

const mutation = useMutation(newTodo => axios.post('/todos', newTodo))

const handleSubmit = useCallback(
    async (newTodo) => {
        await mutation.mutateAsync(newTodo)
        setAnotherState()
        dispatch(createAnotherAction())
    },
    [mutation],
)

쿼리 무효화 Query Invalidation

쿼리 데이터가 stale 상태가 되기만을 마냥 기다릴 수 없는 경우들도 있다.
게시글에 댓글을 작성하고 나면, 서버에서 댓글 목록을 다시 가져올 필요가 있다.
이 경우, 기존에 남아있던 댓글 목록에 대한 정보는 쓸모가 없어지게 된다.
쓸모없어진 query들에 대하여 미리 지정해놨던 staleTime이 넘기 전에 직접 무효화시키고 새로운 데이터를 가져오도록 해야한다.

const queryClient = useQueryClient();

queryClient.invalidateQueries() // 캐시의 모든 쿼리에 대한 무효화

queryClient.invalidateQueries('todos') // todos로 시작하는 모든 쿼리에 대한 무효화

queryClient.invalidateQueries(['todos', 1]) // 해당하는 키를 가지는 쿼리에 대한 무효화

// predicate 옵션을 사용하면 상세한 설정이 가능

queryClient.invalidateQueries({
    predicate: query => query.queryKey[0] === 'todos' && query.queryKey[1]?.rate >= 10, // query key 배열 두번째 원소인 객체의 rage 필드 값이 10 이상인 query에 대한 무효화
})

const todoListQuery = useQuery(['todos', {rate: 15}], fetchTodoList)    // 위의 설정으로 인하여 query가 무효화 됨

장단점은?

장점

  • 비동기 관련 boilerplate를 최소화 할 수 있다.
  • Redux 같은 전역 상태 저장소에 동기적으로 업데이트되는 데이터와 액션만 남길 수 있다. (미들웨어를 없앨 수 있다!)
  • 캐싱, 리패칭을 알아서 지원한다.
  • 다양한 옵션을 제공해서 custom이 쉽다.
  • dev tool이 제공된다.

단점

  • 중앙 집중적인 방식의 데이터 관리를 강제하지 않아서, 비동기 요청이 컴포넌트 각각에 강하게 의존된다.
  • 디버깅이 매우 힘들어질 수 있다.

관련글 더보기

댓글 영역