-
[WizSched] TanStack Query v5를 적용하여 Google CalendarList 받아오기Front-End/Project 2024. 3. 27. 11:47
🍀 목차
CalendarList API
왜 react-query(TanStack Query)를 적용했는가
3 Core Concepts of React Query
적용하기CalendarList API
GET https://www.googleapis.com/calendar/v3/users/me/calendarList
CalendarList API는 사용자의 캘린더 목록에 있는 캘린더를 반환한다.
기본 캘린더(Primary Calendar)는 단일 사용자 계정과 관련된 특별한 유형의 캘린더이다. 사용자 계정마다 자동으로 생성, ID는 일반적으로 사용자의 기본 이메일 주소와 일치한다. 이 기본 캘린더는 삭제하거나 사용자가 소유 해제할 수 없다. 하지만 다른 사용자와 공유할 수는 있다.
기본 캘린더 외에도 명시적으로 여러 다른 캘린더를 만들 수 있다. 이 캘린더도 여러 사용자에게 수정, 삭제, 공유가 가능하다.
API 조회 시 웹 UI의 왼쪽 패널처럼 아래 항목들이 조회되어야 한다.
왜 react-query(TanStack Query)를 적용했는가
앞으로 API 들을 사용하며 일어날 로딩, 에러 처리, 데이터 페칭을 도와줄 유용한 코드가 필요했다.
useFetch
커스텀 훅을 만들어 관리하는 게 좋아 보여 작성을 하고 적용하는 단계에서 useEffect의 의존성 배열을 깔끔하게 작성하기 힘들었다. 또한 불필요한 흐름이 깊어지는 듯했다(선언적으로 보이지 않았다).중복 작성은 피하고 싶고,
exhaustive-deps
경고를 무시하는 것도 싫고, 잘 모르는 useCallback 등의 최적화 함수는 섣부르게 사용하기 싫었고, 비동기 작업들을 보다 선언적으로 작성하고 싶었다. 그러다 우연히 듣게 된 라이브(어쩌면 당신이 몰랐던 React)에서react-query
를 사용하여 개선시킨 방법을 알게 되어 적용해 보기로 하였다.// 기존 useFetch import { isAxiosError } from 'axios'; import { useEffect, useState } from 'react'; import ERRORS, { ErrorResult } from '@/constants/errors'; const useFetch = <T>(fetchMethod: () => Promise<T>): FetchResult<T> => { const [data, setData] = useState<T | null>(null); const [error, setError] = useState<ErrorResult | null>(null); const fetchData = async () => { setError(null); try { const data = await fetchMethod(); setData(data); } catch (error) { if (isAxiosError(error) && error.response) { setError(ERRORS[error.response.status]); } else { setError(ERRORS[0]); } } }; useEffect(() => { fetchData(); }, []); return { data, error, fetchData }; }; interface FetchResult<T> { data: T | null; error: ErrorResult | null; fetchData: () => Promise<void>; } export default useFetch;
아래는 TanStack Query를 사용한 예시이다. 내가 만들고 있던 커스텀 훅의 기능을 모두 제공하면서, 선언적이다.
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> ) }
3 Core Concepts of React Query
Queries
쿼리는 서버로부터 데이터를 비동기적으로 가져오는 데 필요한 정보를 담은 선언적인 종속성이다. 고유한 키(key)에 바인딩되어 있고, 각 쿼리는 서버로부터 특정한 데이터를 가져오기 위해 사용된다. 일반적으로
GET
또는POST
같은 Promise 기반 메서드와 함께 사용된다.만약, 서버의 데이터를 수정하는 메서드를 사용한다면 쿼리 대신 변이(Mutations)를 사용하는 것이 좋다. 쿼리와 유사하나 데이터 수정에 중점을 둔다.
컴포넌트 또는 커스텀 훅에서
useQuery
를 사용하려면 적어도 쿼리에 대한 고유 키(queryKey), 프로미스를 반환하는 함수(queryFn)가 필요하다.import { useQuery } from '@tanstack/react-query' function App() { const info = useQuery({ queryKey: ['todos'], queryFn: fetchTodoList }) }
useQuery가 반환하는 쿼리 결과 객체에는 생산성을 높이기 위한 몇 가지 상태가 포함된다. 아래 상태 중 하나를 가질 수 있다.
- isPending 또는
status === 'pending'
: 쿼리에 데이터가 아직 없다. - isError 또는
status === 'error'
: 쿼리에서 오류가 발생했다. 에러(error)를 확인할 수 있다. - isSuccess 또는
status === 'success'
: 쿼리가 성공했으며 데이터(data)를 사용할 수 있다. - isFetching : isPending, isError, isSuceess 등의 상태에서 쿼리는 언제든지 데이터 페칭 중이면 isFetching이 true가 된다.
대부분은 isPending 상태 확인, isError 상태 확인 후 마지막으로 데이터를 사용할 수 있다고 가정하여 렌더링 하는 것으로 충분하다.
function Todos() { const { isPending, isError, data, error } = useQuery({ queryKey: ['todos'], queryFn: fetchTodoList, }) // status === 'pending' if (isPending) { return <span>Loading...</span> } // status === 'error' if (isError) { return <span>Error: {error.message}</span> } // We can assume by this point that `isSuccess === true`, status === 'success' return ( <ul> {data.map((todo) => ( <li key={todo.id}>{todo.title}</li> ))} </ul> ) }
Mutations
특정 데이터를 가져오는 쿼리와는 달리, Mutations는 일반적으로 데이터 생성/업데이트/삭제 등의 서버 사이드 이펙트를 수행하는 데 사용된다. TanStack Query에서는
useMutation
훅을 내보낸다. 아래는 서버에 새로운 todo를 추가하는 예시이다.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> ) }
mutation도 다음 상태 중 하나를 가질 수 있다.
- isIdle 또는
status === 'idle'
: 현재 유휴 상태(사용 가능 상태) 거나 새로 고침/리셋 상태이다. - isPending 또는
status === 'pending'
: 현재 실행 중이다. - isError 또는
status === 'error'
: 오류가 발생하였다. error를 확인할 수 있다. - isSuccess 또는
status === 'success'
: 변이가 성공, data를 사용할 수 있다.
onSuccess, Query Client의
invalidateQueries
메서드 및setQueryData
메서드와 함께 사용하면 mutation은 매우 강력한 도구가 된다.- onSuccess로 변이 성공 후의 추가적 작업을 수행,
- invalidateQueries로 유효하지 않은 캐시 데이터를 재요청하도록 유도하여 최신 데이터를 가져오도록 할 수 있고,
- setQueryData로 변이 수행 후 즉시 클라이언트 측 데이터를 업데이트시킬 수 있다. 해당 데이터는 캐시 된 데이터이다.
⚠️ 알아두기
'mutate' 함수는 비동기 함수기에 React 16 이전 버전에서는 이벤트 콜백 안에서 직접 사용할 수 없다.
이는 React event pooling(문서)과 관련이 있는데, 17 버전부터는 Event Pooling을 사용하지 않기에 관련이 없다. 요약하면 16 버전까지의 React는 이벤트 처리를 위해 브라우저 native event를 한번 감싼Synthetic Event
를 사용했다. 그렇기에 이를 위한 인스턴스를 매번 생성해야 했고, 이를 개선하고자 Synthetic Event Pool을 만들어 이벤트 발생 시 Pool을 사용했다. 가비지 컬렉터 관점에서도 효율적인 방법 같으나, 이는 비동기 이벤트의 속성 유지에 추가적인 작업(e.persist()
)을 필요로 했다.
function handleChange(e) { // This won't work because the event object gets reused. setTimeout(() => { console.log(e.target.value); // Too late! }, 100); } // 이벤트 객체 속성을 유지시키려면 function handleChange(e) { // Prevents React from resetting its properties: e.persist(); setTimeout(() => { console.log(e.target.value); // Works }, 100); }
mutate 함수 또한 이벤트 콜백 안에서 직접 호출되면 event pooling으로 인해 이벤트 객체의 속성이 사라질 수 있고, 이는 예상치 못한 동작을 일으킬 수 있어(더군다나 사이드 이펙트를 다루기에) 다른 함수로 감싸줘야 한다. 그렇게 되면 이벤트 콜백 안에서 직접 호출되는 것이 아니기에 event pooling에 영향을 받지 않고 이벤트 객체를 안전하게 전달할 수 있다.Query Invalidation
QueryClient에는 쿼리를 오래된 것으로 표시하고 다시 가져올 수 있는 invalidateQueries 메서드가 존재한다.
// Invalidate every query in the cache : 캐시에 있는 모든 쿼리 무효화 queryClient.invalidateQueries() // Invalidate every query with a key that starts with `todos` : todos로 시작하는 키가 있는 모든 쿼리 무효화 queryClient.invalidateQueries({ queryKey: ['todos'] })
쿼리가 무효화되면 쿼리를 stale로 마크하고, 사용 중인 모든 staleTime을 재정의한다. 현재 사용 쿼리 또는 useQuery 등으로 렌더링 되고 있는 경우에도 백그라운드에서 쿼리를 다시 가져온다.
적용하기
yarn add @tanstack/react-query
yarn add -D @tanstack/eslint-plugin-query
코딩하는 동안의 버그와 실수를 예방하기 위한 eslint-plugin-query 사용을 권장하기에 설치하고 적용했다.
아래의 규칙들을 사용한다.
// eslintrc 파일 { "extends": ["plugin:@tanstack/eslint-plugin-query/recommended"] } // 또는 { "rules": { // 쿼리 키는 쿼리 함수에 대한 종속성 배열이어야 한다. "@tanstack/query/exhaustive-deps": "error", // 나머지 인수에 대한 구조 분해 방지. "@tanstack/query/no-rest-destructuring": "warn", // stable한 쿼리 클라이언트를 사용하도록 요구한다(일관성, 안정적 측면에서). "@tanstack/query/stable-query-client": "error" } }
애플리케이션 최상단을 QueryClientProvider로 감싸준다. 아래의 형태가 되었다.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; import ReactDOM from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; import AuthProvider from '@/contexts/AuthProvider'; import router from '@/router'; import '@/index.css'; const queryClient = new QueryClient(); ReactDOM.createRoot(document.getElementById('root')!).render( <React.StrictMode> <QueryClientProvider client={queryClient}> <AuthProvider> <RouterProvider router={router} /> </AuthProvider> </QueryClientProvider> </React.StrictMode>, );
const getCalendarList = async () => { const { data } = await instance.get('/users/me/calendarList'); return data; };
Promise가 반환하는 타입을 지정하기 위해 DefinitelyTyped에 있는 calendar_v3.d.ts파일과 googleapis의 파일들을 비교, 전자가 더 한눈에 들어와 선택하게 되었다. 문서에서는 패키지 매니저를 사용하여 필요한 파일을 추가하는 것을 추천했지만, 내가 필요한 파일은
types/google-apps-script/apis
의calendar_v3.d.ts
파일 하나라서 수동으로 추가하게 되었다. 이 파일은 Ambient declarations로 declare 키워드를 통해 전역적으로 사용할 수 있는 타입이다. 그렇기에tsconfig.json
에 추가 설정을 해주지 않아도 인식이 되어서 include 설정이나 typeRoots 설정을 해주어야 하나 의문이 들었다.// 잘 인식되고, 잘 돌아갔다. const getCalendarList = async (): Promise<GoogleAppsScript.Calendar.Schema.CalendarList> => { const { data } = await instance.get('/users/me/calendarList'); return data; };
include나 typeRoots 옵션은 TypeScript 컴파일러가 타입 정의 파일을 찾는 위치를 지정하기 위해 사용되는데 이는 컴파일러의 동작을 제어하는 데 유용하다. ambient declaration 파일은 명시적으로 import 되기보다는(모듈) 파일 시스템을 통해 자동으로 인식된다. 그렇기에 별도의 설정 없이도 문제는 없으나, 다른 개발자가 typeRoots 등을 확인하여 외부 타입 정의 파일을 찾기 쉬워지는 점이나 인식하는 점을 유용하다 여겨 추가하게 되었다.
query key는 상수로 관리하는 게 좋아 보여 별도의 파일을 만들어 작성했다.
// constants/queryKeys.ts const QUERY_KEYS = { calendar: { list: 'calendarList', }, } as const; export default QUERY_KEYS;
데이터 페칭을 보다 안정적으로 하고 싶어 useQuery 대신 Suspense를 지원하는
useSuspenseQuery
를 사용했다.주의할 점이 있는데 useSuspsnseQuery의 status는 항상 success이다. 공식 문서 제대로 안 읽고 왜 error가 안 잡히는지 절망했었다...
export const useSuspenseCalendarList = () => { const { data } = useSuspenseQuery({ queryKey: [QUERY_KEYS.calendar.list], queryFn: getCalendarList, }); return { data }; };
위와 같이 작성했다.
에러를 잡기 위해서는 폴백 프로퍼티와 react-error-boundary를 사용한다.
import { Suspense } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import ErrorFallback from '@/components/fallback/ErrorFallback'; import LoadingFallback from '@/components/fallback/LoadingFallback'; import { useSuspenseCalendarList } from '@/hooks/queries/calendar'; const CalendarDropDown = () => { const { data } = useSuspenseCalendarList(); // 드롭다운 구현 예정 return <div>{data.items?.map((data) => <div key={data.etag}>{data.summary}</div>)}</div>; }; const CalendarSelector = () => { return ( <div> <ErrorBoundary fallback={<ErrorFallback />}> <Suspense fallback={<LoadingFallback />}> <CalendarDropDown /> </Suspense> </ErrorBoundary> </div> ); }; export default CalendarSelector;
다음 포스팅은
QueryErrorResetBoundary
를 사용하여 쿼리를 다시 시도할 수 있는 상태 코드라면 시도, ErrorFallback 컴포넌트도 정확히 어떤 에러를 마주쳤는지 표시할 수 있게 개선하려고 한다.참고자료
https://developers.google.com/calendar/api/v3/reference/calendarList?hl=ko
https://react-ko.dev/reference/react/Suspense#usage
https://www.npmjs.com/package/react-error-boundary
https://tanstack.com/query/latest/docs/framework/react/reference/useSuspenseQuery
- isPending 또는