Frontend
서버 상태 관리 라이브러리 TanStack Query
리액트 쿼리의 컨셉과 사용법
리액트 쿼리는 API의 요청을 쿼리(query)와 뮤테이션(Mutation)이라는 두 가지로 처리한다.
서버 상태라는 것이 서버에서 관리되고 네트워크 요청을 통해 클라이언트에서 가져와야 하는 데이터라는 것을 알 수 있었다. 서버 상태를 관리하는 것의 주요한 요점들은 데이터 페칭, 캐싱, 동기화, 재검증, 에러 처리, 로딩 상태 관리 등으로 요약할 수 있다.
리액트 쿼리의 사용 흐름
- 쿼리 생성 (useQuery)
- 초기 로딩 (Loading 상태)
- 성공 및 오류 처리 (Success/Error 상태)
- 데이터 갱신 (Refetch/자동 갱신)
- 쿼리 무효화 (Invalidation)
- 캐싱 (Cache 데이터 재사용)
쿼리(Query) 생성과 쿼리의 상태
서버에서 데이터를 가져오는 요청을 쿼리(query)라고 한다. Tanstack query에서는
useQuery
라는 훅을 사용하여 쿼리를 정의하고 데이터를 페칭할 수 있다. useQuery
의 기본적인 형태const { data, error, status, fetchStatus, isLoading, isFetching, isError, refetch, // ... } = useQuery( queryKey, queryFunction, options, )
- 첫 번째 인자는 쿼리 키로, 응답 데이터 고유의 키를 의미한다. 응답 데이터를 캐싱할 때 사용한다.
- 두 번째 인자는 쿼리 함수로, Promise를 반환하는 모든 함수가 들어갈 수 있다. 해당 쿼리 요청을 수행하기 위한 fetch, axios 등의 데이터 페칭 함수를 주로 사용한다.
- 세 번째 인자는 useQuery에 사용할 옵션을 지정하는 객체다.
쿼리 키(Query Key)의 다양한 형태
가장 간단한 형태의 쿼리 키는 상수 값으로 된 배열이다. 만약 계층적인 형태 혹은 중첩된 리소스를 표현하고자 한다면 변수와 함께 배열 키를 만들어주는 것이 좋다.
queryKey: ['users']
queryKey: ['users', 1]
queryKey: ['users', 1, { userId : true }]
쿼리 키는 해싱되므로 객체 내 키의 순서와 관계 없이 동일하게 간주된다.
그러나 배열 원소의 순서는 관계있다.
queryKey: ['users', { admin, male }]
=== queryKey: ['users', { male, admin }]
queryKey: [’users’, admin, male]
≠= queryKey: ['users', male, admin, undefined]
useQuery
에 사용할 수 있는 모든 형태useQuery({ queryKey: ['todos'], queryFn: fetchAllTodos }) useQuery({ queryKey: ['todos', todoId], queryFn: () => fetchTodoById(todoId) }) useQuery({ queryKey: ['todos', todoId], queryFn: async () => { const data = await fetchTodoById(todoId) return data }, }) useQuery({ queryKey: ['todos', todoId], queryFn: ({ queryKey }) => fetchTodoById(queryKey[1]), })
useQuery
훅의 반환 값에는 몇 가지 중요한 상태가 포함되어 있다.isPending
orstatus === ‘pending’
: (펜딩) 쿼리에 아직 받아온 데이터가 없다.
isError
orstatus === 'error
: (에러) 쿼리에 에러가 발생했다.
isSuccess
orstatus === 'success
: (성공) 쿼리가 성공적으로 데이터를 받아왔다.
이외에도 쿼리 상태에 따라 더 많은 정보들을 사용할 수 있다.
error
: 쿼리가isError
상태라면, 이 속성으로 오류를 확인할 수 있다.
data
: 쿼리가isSuccess
상태라면, 이 속성으로 데이터를 사용할 수 있다.
isFetching
: 어떤 상태에서든 쿼리가 데이터를 가져오는 경우에 true 값이다. (백그라운드 리페칭 포함)
import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; // 데이터 페칭 함수 fetchUsers const fetchUsers = async () => { const { data } = await axios.get('/api/users'); return data; }; const Users = () => { // useQuery 훅으로 가져온 데이터를 관리할 수 있다. const { data, isError, isLoading, error } = useQuery({ queryKey: ['users'], queryFn: fetchUsers, }); if (isLoading) return <div>Loading...</div>; if (isError) return <div>Error: {error.message}</div>; return ( <ul> {data.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> ); };
위 코드처럼 useQuery 반환 값으로 특정한 상태(isLoading)를 가져오거나 속성(error)을 가져와 사용해도 되지만, status 객체를 가져와 사용해도 된다.
const { status, data, error } = useQuery({ queryKey: ['users'], queryFn: fetchUsers }); if (isLoading) return <div>Loading...</div>; if (isError) return <div>Error: {error.message}</div>;
staleTime과 gcTime(cacheTime)
useQuery에서는 쿼리에 대한 설정을 할 수 있다. 서버 상태 관리의 주요점 중 하나인 캐싱과 재검증에 대한 설정을 할 수 있는 staleTime과 gcTime(cacheTime)을 알아보자.
staleTime
stale은 ‘신선하지 않은’이라는 뜻으로, 최신 상태가 아니라는 것을 의미한다. 그렇다면 staleTime은 데이터가 ‘신선한’ 상태에서 ‘신선하지 않은’ 상태가 되는
유통 기한
을 말한다. 기본 값이 0이기 때문에 일반적으로는 fetch 후 바로 신선하지 않은 데이터가 된다. 때문에 staleTime을 주지 않으면 해당 쿼리를 사용하는 컴포넌트는 매번 마운트될 때마다 API를 요청한다.gcTime (= cacheTime)
gcTime은 기존의 cacheTime과 같은 역할을 한다. gc는 ‘garbage collection’을 말하는데, 캐시된 데이터가 메모리에서 제거되기 전까지의 시간이다. 데이터가 더 이상 사용되지 않을 것을 가정해서 이 시간이 지나면 캐시에서 제거하도록 하는 것이다. 즉,
캐시 만료 시간
이다. 기본 값은 5분(5*60*1000)이고, SSR 환경에서는 무한(Infinity)이다.staleTime과 gcTime 사용
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes cacheTime: 1000 * 60 * 10, // 10 minutes refetchOnWindowFocus: false, }, }, }); const Root = () => ( <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider> );
- queryClient에 사용하여 제공되는 쿼리들에 전역 옵션을 줄 수 있음
const { data, error, isLoading } = useQuery('user', fetchUserData, { staleTime: 10 * 1000, // 데이터가 10초 동안 신선한 상태로 간주됨 gcTime: 120 * 1000, // 120초 후 캐싱된 데이터 청소 });
- useQuery에 직접적으로 사용할 수 있음
뮤테이션 (Mutation)
뮤테이션(mutation)이란 데이터를 가져오는 쿼리와 달리, 서버에 데이터를 생성, 수정, 삭제하는 요청을 말한다. Tanstack Query에서는 useMutation 훅을 사용하여 뮤테이션을 정의하고 실행할 수 있다. 뮤테이션 함수는 비동기 함수로 리액트 16 버전 이전에서는 제대로 작동하지 않는다.
function App() { const mutation = useMutation({ mutationFn: (newTodo) => { return axios.post('/todos', newTodo) }, }) return ( <div> {mutation.isPending ? ( 'Adding todo...' ) : ( <> {mutation.isError ? ( <div>An error occurred: {mutation.error.message}</div> ) : null} {mutation.isSuccess ? <div>Todo added!</div> : null} <button onClick={() => { mutation.mutate({ id: new Date(), title: 'Do Laundry' }) }} > Create Todo </button> </> )} </div> ) }
useMutation 훅의 상태와 데이터 사용
- 뮤테이션은 특정 순간에 다음 상태 중 하나만 있을 수 있다.
isIdle
또는 status === 'idle' - 변형이 현재 유휴 상태이거나 새로운/재설정 상태isPending
또는 status === 'pending' : 뮤테이션 현재 실행 중isError
또는 status === 'error' : 뮤테이션 에러 발생isSuccess
또는status === 'success'
: 뮤테이션 성공 (데이터 사용 가능)
- 위의 기본 상태 외에도 뮤테이션 상태에 따라 더 많은 정보를 사용할 수 있다.
error
- 변형이 오류 상태인 경우 error 속성을 통해 오류를 확인 가능data
- 뮤테이션이success
상태인 경우 data 속성을 통해 데이터 사용 가능
뮤테이션 성공 시 invalidateQueries()를 사용하기
invalidateQueries는 특정 쿼리를 무효화하여 캐시된 데이터를 갱신하도록 하는 기능으로 데이터 갱신이 필요하거나, 관련된 쿼리를 갱신해야 할 때 사용해서 데이터를 최신 상태로 유지하게 한다.
useMutation 훅을 사용할 때, 데이터 생성 업데이트 삭제 작업이 성공적으로 완료된 후에 관련된 쿼리를 무효화해서 최신 데이터를 가져와서 UI에 표시되는 데이터를 갱신하기 위해서는 invalidateQueries를 사용해준다.
뮤테이션을 사용하면 쿼리 키가 변하지 않고 내부의 데이터만 바뀐다. 그러면 강제로 쿼리를 무효화하고 최신화를 진행해야 하는데 이 때 사용할 수 있는 가장 간단한 방법이 invalidateQueries() 메서드인 것이다.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; const useAddSuperHeroData = () => { const queryClient = useQueryClient(); return useMutation(addSuperHero, { onSuccess(data) { queryClient.invalidateQueries({ queryKey: ["super-heroes"] }); console.log(data); }, onError(err) { console.log(err); }, }); };
- 쿼리 키에 넘겨준 “super-heroes”를 포함하는 모든 쿼리가 무효화된다는 것을 기억하자
enabled: false
옵션을 받은 queryClient는 invalidateQueries와 refetchQueries를 무시한다
쿼리 클라이언트 (QueryClient)
리액트 쿼리를 사용하기 위해서는 캐시와 상호작용 할 수 있는 쿼리 클라이언트를 생성하고, 쿼리 클라이언트 프로바이더(QueryClientProvider)로 컴포넌트를 감싸주어야 한다. 주로 애플리케이션의 루트에 감싸 사용한다.
쿼리 클라이언트는 쿼리와 뮤테이션에 전역 설정을 관리하는 리액트 Context 객체다.
쿼리 클라이언트 객체에는 다양한 기본 옵션을 줄 수 있고 종류가 상당히 많으므로 공식 문서를 참고하는 것이 좋다.
리액트 쿼리를 어떻게 사용해야 할까?
실제로 복잡한 애플리케이션을 구현하는 프로젝트에서 리액트 쿼리를 활용하는 방법의 예시들을 확인해보자.
쿼리 클라이언트 설정 및 전역 상태 관리
import React from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import App from './App'; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 10, // 10 minutes refetchOnWindowFocus: false, }, }, }); const Root = () => ( <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider> ); export default Root;
- 리액트 프로젝트의 루트 컴포넌트(App)에 QueryClientProvider를 제공한다.
- staleTime과 gcTime을 지정한다.
- refetchOnWindowFocus는 사용자가 브라우저를 떠났다가 돌아왔을 때 쿼리가 stale인 경우 백그라운드에서 자동으로 새로운 데이터를 요청하는 기능이다. 기본 값은
true
로 되어 있다.
커스텀 훅을 사용한 쿼리 추상화
// 커스텀 훅 const useUser = (userId) => { return useQuery(['user', userId], () => fetchUserById(userId), { enabled: !!userId, // userId가 있을 때만 쿼리 실행 }); }; // 컴포넌트에서 사용 const UserProfile = ({ userId }) => { const { data, error, isLoading, refetch } = useUser(userId); const handleClickRefetch = useCallback(() => { refetch(); }, [refetch]); if (isLoading) return <span>Loading...</span>; if (error) return <span>Error: {error.message}</span>; return ( <div> {data?.data.map((user: Data) => ( <div key={user.id}>{user.name}</div> ))} <button onClick={handleClickRefetch}>Fetch User</button> </div> ); };
- useQuery를 사용하는 useUser 커스텀 훅을 만들어 사용한다.
- refetch는 쿼리를 수동으로 다시 요청하는 기능이다. 오류가 발생하면 오류만 기록된다.
- 의도적인 오류 발생을 위해 throwOnError 속성을 true로 주는 방법도 있다.
- enabled는 쿼리가 자동으로 실행되지 않도록 할 때 설정하는 옵션이다.
- false의 경우, 쿼리의 status가 pending 상태로 시작한다.
- ⚠️ enabled: false일 경우, queryClient의 invalidateQueries와 refetchQueries를 무시한다.
자동 갱신 및 조건부 갱신 (Polling)
Polling(폴링)
실시간 웹을 위한 기법으로 특정한 시간(주기)를 가지고 서버와 응답을 주고받는 방식을 폴링 방식이라고 한다. 리액트 쿼리에서는
refetchInterval
, refetchIntervalInBackground
를 이용해서 구현할 수 있다.import { useQuery } from '@tanstack/react-query'; const fetchLatestPosts = async () => { const response = await fetch('/api/posts/latest'); if (!response.ok) { throw new Error('Network response was not ok'); } return response.json(); }; const useLatestPosts = () => { return useQuery({ queryKey: ['latestPosts'], queryFn: fetchLatestPosts, refetchInterval: 1000 * 60, // 1분마다 데이터 갱신 refetchIntervalInBackground: true, // 백그라운드에서 갱신 }); }; const LatestPosts = () => { const { data, error, isLoading } = useLatestPosts(); if (isLoading) return <span>Loading...</span>; if (error) return <span>Error: {error.message}</span>; return ( <ul> {data.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> ); };
- refetchInterval
- 숫자로 설정하면 모든 쿼리를 설정한 빈도로 계속 다시 가져온다.
- 함수로 설정하면 빈도를 계산하는 쿼리와 함께 함수가 실행된다.
- refetchIntervalInBackground: boolean
- true일 경우, 해당 탭/창이 백그라운드에 있는 동안 계속해서 다시 가져온다.
- refetchInterval을 사용하여 지속적으로 다시 가져오도록 설정된 쿼리의 경우만 해
백그라운드에서 데이터 재검증
import { useQuery } from '@tanstack/react-query'; const fetchUserSettings = async () => { const response = await fetch('/api/user/settings'); if (!response.ok) { throw new Error('Network response was not ok'); } return response.json(); }; const useUserSettings = () => { return useQuery({ queryKey: ['userSettings'], queryFn: fetchUserSettings, staleTime: 1000 * 60 * 10, // 10분 동안 신선한 상태로 간주 gcTime: 1000 * 60 * 15, // 15분 동안 캐시에 유지 refetchOnWindowFocus: true, // 윈도우가 포커스를 받을 때마다 재검증 }); }; const UserSettings = () => { const { data, error, isLoading } = useUserSettings(); if (isLoading) return <span>Loading...</span>; if (error) return <span>Error: {error.message}</span>; return <div>{data.theme}</div>; };
- 특정 쿼리에 staleTime과 gcTime을 주어서 데이터를 캐싱한다.
- refetchOnWindowFocus 옵션으로 브라우저가 포커싱될 때마다 재검증하도록 한다.
낙관적 업데이트
낙관적 업데이트(Optimistic Update)는 네트워크 속도가 느리거나 서버 속도가 느릴 때 유저의 액션에 대한 응답을 기다릴 필요 없이 클라이언트 UI만 업데이트 시켜준 뒤, 서버를 통해 검증받고 업데이트하거나 롤백하는 방식이다.
- 뮤테이션 요청
- 이전 데이터를 저장해두고, 요청과 함께 UI를 미리 업데이트
- 실패 시 이전 데이터를 사용해서 롤백
const useAddSuperHeroData = () => { const queryClient = useQueryClient(); return useMutation({ mutateFn: addSuperHero, onMutate: async () => { await queryClient.cancelQueries(["super-heroes"]); // 이전 값 저장해두기 const previousHeroData = queryClient.getQueryData(["super-heroes"]); // 새로운 값으로 낙관적 업데이트 진행 queryClient.setQueryData(["super-heroes"], (oldData: any) => { return { ...oldData, data: [ ...oldData.data, { ...newHero, id: oldData?.data?.length + 1 }, ], }; }); // 실패하면 되돌릴 수 있게 이전 데이터를 담은 객체를 반환 return { previousHeroData }; }, // 실패하면 onMutate에서 반환된 context를 사용하여 이전 데이터로 롤백 onError(error, hero, context: any) { if (context?.previousHeroData) { queryClient.setQueryData(["super-heroes"], context.previousHeroData); } }, // 성공, 실패 여부에 무관! 항상 refetch! // 데이터 변환이 있을 해당 쿼리를 무효화해준다. onSettled() { queryClient.invalidateQueries(["super-heroes"]); }, }); };
- onSettled는 뮤테이션의 실패와 성공 여부에 상관 없이 요청이 끝나면 작동한다.
- ⚠️ invalidateQueries는 useQuery나 관련 훅들을 통해 쿼리가 렌더링되고 있는 경우, 백그라운드에서도 리페칭된다. (렌더링되고 있는 경우 → refetch)