- Powerful asynchronous state management for TS/JS, React, Solid, Vue, Svelte and Angular
우리가 흔히 리액트 쿼리로 알고있는 라이브러리의 docs 홈페이지의 소개 첫 문장입니다. (처음엔 react-query 로 시작했다가 다른 라이브러리도 지원하기 위해 이름을 TanStack Query 로 변경했다 하네요.) 강력한 비동기 상태 관리 라이브러리 라고 소개를 하고 있는데요. 저는 이 말을 몸소 이해하는데는 꽤 긴 시간이 걸렸습니다. 저는 이 리액트 쿼리를 사용하며 리액트에서의 제가 상태 관리하는 방식이 어떻게 변화했고, 이 리액트 쿼리는 어떤 문제를 해결해 주었는지 정리해보려고 합니다.
1. 이 라이브러리는 왜 만들었을까?
- Toss out that granular state management, manual refetching and endless bowls of async-spaghetti code. TanStack Query gives you declarative, always-up-to-date auto-managed queries and mutations that directly improve both your developer and user experiences.
docs 홈페이지의 소개 두번째 문장입니다. 비동기 관련 스파게티 코드를 날려주고, 선언적이고, 항상 최신화된 자동관리 쿼리와 뮤테이션을 해줘서 개발자와 실제 사용자의 경험을 둘 다 증진시켜주겠다. 이렇게 이야기하네요. 저는 이 글을 적기 전까지 홈페이지 소개 두번째 문장을 제대로 읽어본 적이 없는데요. 제가 리액트 쿼리를 사용해보며 개인적으로 느꼈던 개발자 의도와 일치하는 것 같습니다.(이런 문제들을 해결하기 위해 만들었구나!) 이 문장과 제가 생각하는 바를 정리해서 리액트 쿼리를 왜 만들었을까? 에 대한 대답을 하자면
- 비동기 상태 관리 관련 보일러플레이트 코드를 줄여 개발자 경험을 개선하겠다.
- 비동기 상태 관련해서 데이터를 쿼리와 뮤테이션을 통해 사용자 경험을 개선하겠다.
입니다. 제가 리액트 쿼리를 사용하면서 느꼈던 장점도 정확하게 이랬습니다. 그래서 리액트 쿼리는 그들이 말하는 의도에 맞게 잘 만든 라이브러리 라고 생각이 듭니다.
2. 보일러플레이트 코드를 줄여주는 효과
사실 리액트 쿼리가 딱히 없어도 리액트에서 비동기 상태 관리(주로 백엔드의
GET
메소드로 가져오는 데이터들)를 할 수는 있습니다. 하지만, 리액트에서 비동기 상태 관리를 하려면 항상 유사한 state 와 사이드이펙트 함수들을 선언하곤 했습니다.const Page() => { const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); const [data, setData] = useState<SomeData | null>(null); useEffect(() => { const fetchDataAsync = async () => { try { setIsLoading(true); const fetchedData = await fetchData(); setData(fetchedData); setIsError(false); } catch (e) { setIsError(true); } finally { setIsLoading(false); } }; fetchDataAsync(); }, []); if (isLoading) return <Loading />; if (isError) return <Error />; return <div>{data}</div> }
예시의 코드 처럼 많은 state 를 두고,
useEffect
안에서는 async
함수를 콜백으로 넣을 수 없으니 내부에서 async
함수 선언해서 실행하고, Page 가 처음 마운트 될 때, 한번만 실행하기 위해 useEffect
의 의존성 배열에 아무것도 넣지않는 패턴, 저는 많이 봤던 것 같습니다. 위의 코드는 아래와 같은 코드와 거의 같은 의도로 동작합니다.
const Page() => { const { data, isLoading, isError } = useQuery(['key'], fetchData); if (isLoading) return <Loading />; if (isError) return <Error />; return <div>{data}</div> }
직접
setState
를 호출해서 상태를 바꿔주거나 하는 그런 여러가지 로직이 useQuery
훅 안에 들어가있어서, 페이지는 정말 페이지의 동작에 관한 로직만 담을 수 있게 됩니다. 단순히 페이지 수준에서 백엔드 API 를 fetch 해오는 보일러플레이트 코드 뿐만 아니라, 백엔드 API 를 호출하는 것 자체를 줄이기 위한 캐시 레이어를 따로 두거나 하는 경우에도, 이를 리액트 쿼리에서 직접 해결해주게 됩니다.
3. 쿼리와 뮤테이션을 통한 비동기 상태 관리 방식
리액트 쿼리에서는 자체적인 비동기 상태 관리 레이어가 있어서 리액트 쿼리에서 제공해주는 API 는 우선 자체 레이어의 데이터를 먼저 확인하게 됩니다. 즉, 리액트 쿼리는 내부적으로 캐시를 가지고 있고(캐시의 기본 설정은 자바스크립트 객체로 선언됩니다.), 비동기 데이터를 사용자가 요청할 때 내부 캐시를 확인하며 비동기 데이터를 직접 호출하기도 하고, 캐시에 있는 데이터를 제공하기도 합니다.
리액트 쿼리는
query-core
라는 패키지에서 직접 QueryCache
라는 클래스를 만들어 캐시 관리를 합니다. (해당 파일을 보시면 캐시 관리 로직을 확인해보실 수 있습니다.) 리액트 쿼리의 캐시는 리액트 쿼리에서 직접 만든 자바스크립트의 객체입니다. 이
QueryCache
객체가 캐시 데이터를 저장, 수정, 삭제를 수행하고, 필요한 경우 백엔드 데이터를 직접 호출해서 데이터를 주는 대신 캐시에 저장되어있던 데이터를 꺼내줍니다. 리액트 쿼리의 비동기 캐시 데이터는 여러가지 상태를 가집니다.
- inactive
현재 컴포넌트에서 데이터를 사용하지 않는 상태
- fresh
백엔드의 데이터와 일치되었다고 여겨지는 상태
- stale
캐시된 꽤 시간이 지나서 백엔드에서 데이터를 검증해보고 싶은 상태, 이 상태에서는
Stale-While-Revalidate
작업을 수행합니다.- fetching
데이터 로딩 상태
Stale-While-Revalidate
는 백엔드에 요청 시 캐시된 데이터는 먼저 제공하되, 백엔드에 직접 요청도 같이 해서 캐시 데이터가 백엔드 데이터와 같은지 확인 작업을 하게 됩니다. 리액트 쿼리는 이러한 상태들을 사용자가 정의 할 수 있게 옵션 기능을 제공합니다.
import { useQuery, useMutation, useQueryClient, QueryClient, QueryClientProvider, } from '@tanstack/react-query' import { getTodos, postTodo } from '../my-api' // Create a client const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, refetchOnMount: false, retry: false, }, }, }) function App() { return ( // Provide the client to your App <QueryClientProvider client={queryClient}> <Todos /> </QueryClientProvider> ) } function Todos() { // Access the client const queryClient = useQueryClient() // Queries const query = useQuery({ queryKey: ['todos'], queryFn: getTodos }) // Mutations const mutation = useMutation({ mutationFn: postTodo, onSuccess: () => { // Invalidate and refetch queryClient.invalidateQueries({ queryKey: ['todos'] }) }, }) return ( <div> <ul>{query.data?.map((todo) => <li key={todo.id}>{todo.title}</li>)}</ul> <button onClick={() => { mutation.mutate({ id: Date.now(), title: 'Do Laundry', }) }} > Add Todo </button> </div> ) } render(<App />, document.getElementById('root'))
리액트의
Context
API 로 캐시 데이터 정책을 공유합니다. 물론 세밀하게 쿼리마다 다르게 옵션을 줄 수도 있습니다.- 그렇다면 사용자는 어떻게 리액트 쿼리의 비동기 상태 관리 데이터에 접근해 사용할 수 있을까요?
캐시는 일반적으로
HashMap
의 구조를 많이 띄고 있는데, 리액트 쿼리도 같습니다. 캐시에 접근할 key
와 백엔드에서 가져온 데이터를 value
하는 HashMap
구조로 이해하면 됩니다. (실제 자료구조도 HashMap
기반 입니다.) 리액트 쿼리에서는 이
key
이름을 queryKey
라고 합니다. queryKey
는 string[]
타입입니다. // A list of todos useQuery({ queryKey: ['todos'], ... }) // Something else, whatever! useQuery({ queryKey: ['something', 'special'], ... })
value
는 비동기로 클라이언트가 요청하는 데이터입니다. 그래서 value
에 해당하는 것은 단순한 값이 아닌 queryFunction
으로 요청하며, 이 queryFunction
은 Promise
를 리턴하는 함수입니다.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]), })
앞선 설명과 예시코드를 살펴보시면 몇가지 주요 특징이 있습니다.
queryKey
와queryFn
은 항상 짝으로 다니게 됩니다.
queryKey
에는 변수가 들어갈 수 있으며, 이는queryFn
이 인자를 가질 때 이 인자값이queryKey
안의 변수 값이 되도록 합니다.
queryKey
하나당queryFn
의 return 하는 data 값이 일대일대응됩니다.
즉,
queryKey
값 하나 당 queryFn
이 return 하는 data 값 하나를 매칭하여 사용하는 것을 전제하에 개발된 것입니다. 그래서 queryKey
를 비동기 데이터 값 하나라고 생각하고 사용해도 됩니다.그래서 이
queryKey
를 하나의 비동기 데이터 값과 매칭해주는 TanStack 쿼리 전용 라이브러리도 있었습니다.query-key-factory
라는 라이브러리 입니다. 사용하다보면 queryKey
의 배열 길이가 길어지면 사용이 불가능한 그런 버그가 있었는데, 고쳐지지 않아서 결국 새로 query key-function store 를 직접 구현할까 고민했던 생각이 납니다. queryKey
와 queryFn
을 한데 묶어서 한번에 사용한다는 query-keyfactory
의 사상은 좋은 것 같네요.export const centerQueries = createQueryKeys('centers', { list: (option: 'bookmark' | 'new_setting' | 'newly_registered') => ({ queryKey: [option], queryFn: () => getCenterList(option), }), }); // usage: export const useGetCenterList = ( option: 'bookmark' | 'new_setting' | 'newly_registered' ) => { return useQuery({ ...centerQueries.list(option), enabled: Boolean(option), }); };
➕ v5 에서는
queryOptions
라는 기능으로 query-key-factory
라는 기능이 개편되어 신 기능으로 들어온 것 같습니다. v5 버전으로 올리는게 정말 좋아보이네요!import { queryOptions } from '@tanstack/react-query' function groupOptions(id: number) { return queryOptions({ queryKey: ['groups', id], queryFn: () => fetchGroups(id), staleTime: 5 * 1000, }) } // usage: useQuery(groupOptions(1)) useSuspenseQuery(groupOptions(5)) useQueries({ queries: [groupOptions(1), groupOptions(2)], }) queryClient.prefetchQuery(groupOptions(23)) queryClient.setQueryData(groupOptions(42).queryKey, newGroups)
이
queryKey
와 queryFn
을 인자로 리액트 쿼리에서 제공하는 useQuery
라는 훅을 통해서 비동기 상태를 관리 할 수 있게 됩니다.function Todos() { const { isPending, isError, data, error } = useQuery({ queryKey: ['todos'], queryFn: fetchTodoList, }) if (isPending) { return <span>Loading...</span> } if (isError) { return <span>Error: {error.message}</span> } // We can assume by this point that `isSuccess === true` return ( <ul> {data.map((todo) => ( <li key={todo.id}>{todo.title}</li> ))} </ul> ) }
예시 코드의
data
는 리액트 쿼리의 코어 기능에 의해 마치 리액트의 state 처럼 동작하게 됩니다.- 캐시 데이터를 수정하거나 할 수 없을까요?
일반적으로 캐시 데이터는 어떤 정책을 정해놓고, 그 정책 안에서 캐시 데이터를 관리합니다. 하지만, 캐시 데이터를 직접 개발자가 수정하거나 비워주는 일도 많이 일어납니다. 리액트 쿼리에서는 캐시 데이터를 수정할 때,
invalidate
라는 표현을 합니다. 해당 queryKey
가 유효하지 않다는 처리를 해주는 것입니다. 이러면 자동적으로 query-core
에서 캐시 데이터를 주는 것이 아니라 다시 백엔드 데이터를 직접 호출해 해당 값을 제공하게됩니다.// Invalidate every query in the cache queryClient.invalidateQueries() // Invalidate every query with a key that starts with `todos` queryClient.invalidateQueries({ queryKey: ['todos'] })
사실, 캐시 데이터를 직접 수정할 일은 평상시에는 없지만, 수정할 일이 확실하게 필요할 때가 있습니다. 바로 백엔드 데이터를 클라이언트가 수정 했을 때 입니다. (http 메소드로 치면
POST
, PUT
, DELETE
를 했을 때) 이때는 확실히 캐시 데이터를 직접 수정해주지 않으면, 캐시 데이터와 백엔드 데이터가 확실하게 다르다는 것을 알 수 있습니다. 방금 내가 백엔드 데이터를 변경 했기 때문입니다.- 그렇다면 리액트 쿼리를 통해 백엔드 데이터를 변경하는 방법은 무엇일까요?
function App() { const { isPending, isError, isSuccess, mutate } = useMutation({ mutationFn: (newTodo) => { return axios.post('/todos', newTodo) }, }) return ( <div> {isPending ? ( 'Adding todo...' ) : ( <> {isError ? ( <div>An error occurred: {mutation.error.message}</div> ) : null} {isSuccess ? <div>Todo added!</div> : null} <button onClick={() => { mutate({ id: new Date(), title: 'Do Laundry' }) }} > Create Todo </button> </> )} </div> ) }
useMutation
이라는 훅을 통해 비동기 상태 관리 데이터를 mutation
할 수 있도록 했습니다. 이러면 일단 백엔드 API 를 호출해 서버 데이터를 변경했습니다. 하지만, 이 코드만 가지고는 Todo list 를 GET
해오는 queryKey
의 값이 업데이트가 되지 않습니다. 짧은 시간 안에 생성 후 Todo list 가 호출 된다면, 새로 추가한 Todo 는 보이지 않을 것입니다.function App() { const { data } = useQuery({ queryKey: ['/todos'], queryFn: getTodoList }); const mutation = useMutation({ mutationFn: (newTodo) => { return axios.post('/todos', newTodo) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['/todos'] }); }, }) 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> {data.map(renderTodo)} </div> </div> ) }
예시와 같이 mutation 성공 시, 관련된 캐시 데이터
queryKey
를 invalidate 해 주어야 새로 업데이트가 될 것입니다.4. 웹 클라이언트에서의 상태 관리 분류
저는 리액트 쿼리가 server state 와 client state 를 나누어 관리 할 수 있도록 해주는 라이브러리라고 생각합니다. 제가 계속해서 비동기 상태라고 했던 백엔드 데이터 기반의 상태 데이터들을 리액트 쿼리 docs 에서는 server state 라고 합니다. 그리고 서버 쪽에서는 전혀 알 필요 없는 상태(다이얼로그의 온/오프, 사이드바 펼쳐짐 여부 등) 를 client state 라고 합니다. 제가 리액트 쿼리를 쓰기 전까지는 이렇게 상태를 나누어 관리한 적이 없었는데, server state 들을 모두 리액트 쿼리에 위임하고 나니 state 를 server state, client state 로 나누어 관리하는 것이 편하다는 것을 느꼈습니다. 이제 serve state 의 경우 리액트 쿼리, client state 의 경우 기본 react hooks 또는 외부 라이브러리 (mobx, recoil, zustand 등)를 사용해 관리하면 되겠다고 기준을 세우게 되었습니다.
5. Good case vs Bad case
queryKey
사용 케이스
리액트 쿼리를 동료들과 같이 사용하고 써보면서 가장 중요했던 것은
queryKey
를 잘 정의하는 것이었습니다.queryKey
는 앞 배열에 있는 값을 invalidate 한다면, 그 하위에 있는 배열을 전부 자동적으로 데이터를 리프레시 합니다. 만약 위의 queryKey
를 가진 데이터가 캐시된 상태에서 ‘users’
키가 invalidate 된다면, 하위 ‘history’
인 모든 캐시 데이터들 또한 같이 invalidate 됩니다. 이런 기능을 잘 활용하려면 queryKey
를 잘 작성하고, 관리할 줄 알아야 합니다. 예전 사이드 프로젝트에서는 백엔드의 URI 를 잘 설계해두어서, URI 를 토대로
queryKey
로 사용해본 적이 있습니다. 백엔드가 URI 설계 원칙(RFC-3986)을 잘 지킨 경우, URI 의 /
구분자에 따라 그대로 queryKey
를 사용해도 된다고 생각합니다. 이게 가장 좋은 사용 케이스 인 것 같습니다.- Bad Case
const { data } = await axios.get<Pagination<UserHistoryResponse>>( `/users/history`, { params: { page: pageParm, }, } ); // queryKey const queryKey = ['usersHistory'];
이렇게 모든 URI 에 따라
queryKey
를 배열로 관리하지 않고, 하나의 값으로 관리하면, 백엔드 데이터의 캐시를 계층적으로 관리할 수 없습니다.- Good Case
const { data } = await axios.get<Pagination<UserHistoryResponse>>( `/users/history`, { params: { page: pageParm, }, } ); // queryKey const queryKey = ['users', 'history'];
이렇게 사용한다면,
queryKey
를 이용한 쿼리 데이터 리프레시 기능을 십분 잘 활용할 수 있습니다. useQuery
를 커스텀훅으로 감싸서 사용하던 것
저는 처음에,
useQuery
등을 다음과 같이 커스텀 훅으로 묶어서 사용하곤 했습니다.- Bad Case
export const useGetGroups = (id: numbe) => { return useQuery({ queryKey: ['groups', id], queryFn: () => fetchGroups(id), enabled: Boolean(id), }); }; export const useGetGroups = (id: number, options?: UseQueryOption<...>) => { return useQuery({ queryKey: ['groups', id], queryFn: () => fetchGroups(id), enabled: Boolean(id), ...options }); };
이 케이스의 단점은 이 커스텀 훅을 사용하는 페이지에서는 option 을 변경해서 사용하기 쉽지 않다는 점이 있습니다. 이렇게 기준을 정하다보니, 같은 API 를 사용하는 쿼리임에도 option 이 달라 새로운 훅을 생성해주어야 한다거나, option 을 인자로 넘겨주는 방식으로 사용했는데, 이 option 을 넘겨주는 부분이 typescript 에서는
UseQueryOption
의 타입이 명확하게 정의 되지 않으면 에러가 나서, 배보다 배꼽이 더 큰 상황이 있었습니다.또한, 한 페이지에 많은
GET
메소드를 사용할 때에도, useQueries
를 사용하기 어려운 상황이었습니다.- Good Case
v5 에서
QueryOption
기능이 추가되면서, 이를 사용하는 것이 무조건 더 좋은 케이스 인 것 같습니다. 커스텀 훅으로 사용하던 케이스에서의 장점 또한 가지면서도, 유동적으로 옵션을 변경하기 좋기 때문입니다.import { queryOptions } from '@tanstack/react-query' export function getGroupOptions(id: number, options?: UseQueryOption) { return queryOptions({ queryKey: ['groups', id], queryFn: () => fetchGroups(id), staleTime: 5 * 1000, ...options }) } // usage: useQuery(getGroupOptions(1)) useSuspenseQuery(getGroupOptions(5)) useQueries({ queries: [getGroupOptions(1), getGroupOptions(2)], }) queryClient.prefetchQuery(getGroupOptions(23)) queryClient.setQueryData(getGroupOptions(42).queryKey, newGroups)
이전에 커스텀 훅으로 정의해서 사용하던 것에 비해서, 인자들만 묶어 정의하기 때문에,
useQueries
사용하기에도 좋고, 공통된 로직이 있다면 묶어서 사용하고, 일부만 덮어쓰기에도 좋은 방법이라고 생각합니다.6. 결론
제게 리액트 쿼리는 리액트에서의 상태관리 방식의 기준을 바꿔준 잘 만든, 유용한 라이브러리라고 생각합니다. 리액트 쿼리는 비동기 상태 관리에 대한 보일러플레이트를 크게 줄여주고, 백엔드 API 캐시를 쉽게 사용 할 수 있도록 도와줍니다. 사용자로서 이 라이브러리를 사용할 때 가장 중요한 것은
queryKey
를 어떻게 설계해서 사용할지 이며, 이는 URI 설계 원칙을 반영하면 아주 좋을 것입니다. 또한, queryKey
와 queryFn
을 항상 일대일대응하게 관리하는 것이 좋습니다.