-
React-Query를 사용해보자React-query 2024. 11. 9. 20:42
프론트를 공부하면서 자주 듣는 단어였던 '상태관리' 에 관하여 와닿지 않을시점, 프로젝트를하며 너무나도 많은 useState와 서버상태까지 관리하며, 동시에 화면에 렌더링하기위한 수많은 useEffect에 지칠 시점, 리엑트쿼리에 대하여 알게되었다. 그때의 내가 봤으면 좋았을 만한 것들을 작성해 보고 싶었다.
서론
1. 보일러 플레이트
서버에서 하나의 data를 가지고온다고 가정해보자.
const [data. setData] = useState(null) const fetchData = async () => { const res = await axios.get('/api/data'); setData(res.data); }
res에는 호출한 데이터가 담겨있을 것이다. 그러나 이 데이터가 완벽하게 도착할 순 없다. 서버의 문제가 생겼다는지, 엔드포인트가 바뀌었던지 등 여러 에러들이 존재할 것이다. 이를 방지하고 해당 에러에 대응하기위해 err state를 추가한다.
const [data. setData] = useState(null) const [error, setError] = useState(null); const fetchData = async () => { try { const response = await axios.get('/api/data'); setData(response.data); } catch (err) { setError(err); } }
그리고 이 데이터를 안전하게 렌더링 하기위해선 로딩 state가 필요하며 동시에 state를 다루기 위한 useEffect가 필요하다.
데이터를 불러올 떄의 트리거를 다루던지, 또는 하이드레이션 오류를 피하기 위한 등 여러목적에 맞게 사용될것이다.
const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { const fetchData = async () => { setIsLoading(true); try { const response = await axios.get('/api/data'); setData(response.data); } catch (err) { setError(err); } finally { setIsLoading(false); } }; fetchData(); }, []);
여기까지가 단 하나의 데이터를 불러올때 필요한 보일러 플레이트다. 그러나 이러한 상황이 한 곳이 아닌 API를 호출할 때 마다 필요할 것이며, 각 코드를 컴포넌트마다 반복적으로 작성해야되는 불편함이 생기게된다.
2. 불필요한 호출
예를 들어 쇼핑몰이 있다고 가정하자. 현재 사진에 보여지는 두 가지 data는 우리 쇼핑몰의 BEST 상품이다. 처음 이 페이지를 마운트할때, api호출로 해당데이터를 렌더링을 하고, 다른 페이지로 이동했다가 다시 이 페이지로 돌아왔을 때 같은 api호출이 또 사용될 것이다. 이 데이터는 아마 하루, 아니 일주일이 지나도 우리 사이트에서 가장 높은 추천수를 가진 아이템일 것이다. 즉, 데이터를 최신으로 계속 유지할 필요없이 단 한번의 호출로 이 값을 저장하고 다시 보여주면 좋지 않을까? 란 생각을 할 수 있게되는 것이다.
마찬가지로 이러한 상황이 BEST상품 뿐만 아닌, 모든 상품들이 될 수도 있고, 그 상품들에 관한 카테고리, 검색어가 바뀔때마다 매번 같은 데이터를 호출해야 되는 불편한 상황에 놓여지게 될 수도 있다는 것이다.
본론
식당의 상황을 리엑트쿼리에 입혀보자. (메뉴판은 주방에서 사장이 직접 점원에게 전달한다)
자주 발생하는 상황에 대하여, 기존의 경험 과 리엑트쿼리를 사용했을때의 경험을 나누어 비유.
1. 캐싱
손님이 식당에 입장하면, 점원은 주방에가서 메뉴판을 받아와서 A손님에게 건넨다. 그리고 A손님이 주문을 완료할떄쯤, B손님이 한팀 더들어온다.
기존 - 점원은 다시 주방에가서 똑같은 메뉴판을 또 가지고와서 B손님에게 건넨다.
react-query - 메뉴판을 가지러 또 주방에 가지 않을 것이다. A손님에게 건넨 메뉴판을 다시 B손님에게 건넨다.
2. 자동갱신
이 식당은 점심메뉴와 저녁메뉴가 다르다. 그리고 손님은 오후 2시부터 있었고 현재 오후 5시가 되어간다.
기존 - 손님이 점원한테 직접 물어본다. “혹시 저녁메뉴로 바뀌었나요?” , “아 그렇네요 바뀌었네요”
react-query - 손님이 지인들과 얘기중에 점원이 자연스럽게 테이블 위의 점심메뉴판을 저녁메뉴판으로 바꿔놓는다.
3. 중복요청 방지 - 여러컴포넌트 동시요청
이번엔 손님 두팀이 동시에 가게에 입장했다. 점원이 손님들에게 메뉴판을 가져다 주어야한다.
기존 - 주방에 들려서 A팀에다가 메뉴판을 주고 다시 주방에 가서 메뉴판을 챙기고 B팀한테 가져다준다.
react-query - 주방에 가서 메뉴판 2개를 동시에 챙기고 A팀과 B팀 각각 제공한다.
4. 에러처리
지금은 주방이 너무 바쁜상황이다. 손님에게 메뉴판을 가져다 주어야 하는 상황
기존 - 사장님 저 메뉴판..”안돼 바뻐” → 손님에게 가서 메뉴판을 가지고 오지 못했다고 전달
react-query - 사장님 저 메뉴판.. “안돼 바뻐” → 조금 기다렸다가 다시 “사장님 지금은요..?” → 지금도안되면 또 기다리기 → 그래도안되면 손님에게 가서 → 상황전달
리엑트쿼리는 이러한 점들을 아주 쉽게, 더 간단하게, 몇가지의 설정으로 데이터를 다룬다.
설치명령어
# npm 사용시 npm install @tanstack/react-query # yarn 사용시 yarn add @tanstack/react-query # pnpm 사용시 pnpm add @tanstack/react-query
리엑트쿼리 개발자 도구 설치명령어 - 권장
# npm 사용시 npm install @tanstack/react-query-devtools # yarn 사용시 yarn add @tanstack/react-query-devtools # pnpm 사용시 pnpm add @tanstack/react-query-devtools
Next.js - Pages Router
// pages/_app.tsx import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // 1분 gcTime: 60 * 60 * 1000, // 1시간 retry: 3, // 실패시 3번 재시도 }, }, }) function MyApp({ Component, pageProps }) { return ( <QueryClientProvider client={queryClient}> <Component {...pageProps} /> <ReactQueryDevtools initialIsOpen={false} /> {/* 개발 도구 */} </QueryClientProvider> ) } export default MyApp
App Router - 무한스크롤을 사용하기위한 useinfinitequery를 사용한다면 appRouter추천
// app/providers.tsx 'use client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { useState } from 'react' export default function Providers({ children }) { const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // 1분 gcTime: 60 * 60 * 1000, // 1시간 retry: 3, // 실패시 3번 재시도 }, }, }) return ( <QueryClientProvider client={queryClient}> {children} <ReactQueryDevtools initialIsOpen={false} /> </QueryClientProvider> ) } // app/layout.tsx import Providers from './providers' export default function RootLayout({ children, }: { children: React.ReactNode }) { return ( <html lang="ko"> <body> <Providers>{children}</Providers> </body> </html> ) }
분리 : React Query는 클라이언트 사이드 라이브러리 이고, 앱라우터의 layout.tsx는 기본적으로 서버 컴포넌트. 따라 서버 컴포넌트와 클라이언트 컴포넌트를 분리함으로 써 필요한 부분만 클라이언트에서 실행되도록 하기 위함.
v4문법
// 예전 방식 useQuery('items', fetchItems) // 또는 useQuery(['items', id], fetchItems)
v5 문법
// 최신 문법 useQuery({ queryKey: ['items'], queryFn: fetchItems }) // 파라미터가 있는 경우 useQuery({ queryKey: ['items', id], queryFn: () => fetchItems(id) })
리엑트 쿼리 핵심 개념
query - 데이터의 fetching용이다. CRUD에서 Reading에 만 사용된다.
1. queryKey
캐싱된 데이터를 식별하는 고유키이다. 리엑트쿼리에서는 이 키를 사용하여 데이터를 캐싱하고 관리한다. 즉, 식별표 이름표와 같다고 볼 수 있다.
2. queryFn
쿼리펑션은 실제 데이터를 가져오는 비동기 함수이다. 다시말하면 promise를 반환하는 함수로서 데이터를 resolve하거나 error를 throw 하는 역할이다.
const { data: item, isLoading, error } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
가장 기본적인 쿼리의 형태이다. 객체구조할당으로 data와 isLoading , error 등을 반환한다.
3. useQuery가 반환하는 것
const { // 데이터 관련 data, // 가져온 데이터 dataUpdatedAt, // 데이터가 마지막으로 업데이트된 타임스탬프 // 로딩 상태 관련 isPending, // 첫 로딩 진행 중 isLoading, // 첫 로딩 중이며 pending 상태 isFetching, // 데이터를 가져오는 중 (백그라운드 포함) // 에러 관련 error, // 에러 객체 isError, // 에러 발생 여부 // 상태 플래그 isSuccess, // 쿼리가 성공적으로 데이터를 가져왔는지 isStale, // 데이터가 오래되었는지 // 재요청 관련 refetch, // 수동으로 다시 요청하는 함수 isFetching, // 데이터를 다시 가져오는 중인지 // 기타 status, // 'pending' | 'error' | 'success' fetchStatus, // 'fetching' | 'paused' | 'idle' } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
4. useQuery - config커스텀
기본적인 콜백 함수들
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, // 성공 시 콜백 onSuccess: (data) => { console.log('데이터 로드 성공:', data); }, // 에러 발생 시 콜백 onError: (error) => { console.error('데이터 로드 실패:', error); }, // 성공/실패 상관없이 완료시 콜백 onSettled: (data, error) => { console.log('쿼리 완료', { data, error }); } });
캐시 설정
const { data } = useQuery({ queryKey: ['todos'] as const, queryFn: fetchTodos, // 캐시 설정 staleTime: 1000 * 60 * 5, // 5분 gcTime: 1000 * 60 * 30, // 30분 // 초기 데이터 initialData: [], // 초기 데이터가 stale한지 여부 initialDataUpdatedAt: 0, // placeholder 데이터 placeholderData: previousData });
자동 업데이트 설정
const { data } = useQuery({ queryKey: ['todos'] as const, queryFn: fetchTodos, // 자동 리페칭 설정 refetchInterval: 1000 * 30, // 30초마다 refetchIntervalInBackground: true, // 백그라운드에서도 refetchOnWindowFocus: true, // 윈도우 포커스시 refetchOnMount: true, // 컴포넌트 마운트시 refetchOnReconnect: true, // 네트워크 재연결시 // 수동으로 리페치 조건 설정 enabled: isEnabled });
데이터 상태
데이터 상태의 종류
// 데이터 상태의 기본 타입들 type QueryStatus = | 'fresh' // 신선한 데이터 | 'stale' // 오래된 데이터 (신선하지 않은, 탁한) | 'fetching' // 가져오는 중 | 'paused' // 일시 중지 | 'inactive' // 비활성 | 'deleted'; // 삭제됨
시간설정과 상태관리
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5분 gcTime: 1000 * 60 * 30, // 30분 retry: 3, }, }, }); function TodoList() { const { data } = useQuery({ queryKey: ['todos'] as const, queryFn: fetchTodos, staleTime: 1000 * 60 * 5, // 5분 동안 fresh 상태 유지 gcTime: 1000 * 60 * 30, // 30분 동안 캐시 유지 }); }
데이터 상태의 흐름도
데이터가 처음 fetch되면 초기엔 ‘fresh’ 상태로 'staleTime' 으로 설정해놓은 시간동안 유지가 된다. 이 상태가 되면 자동 리페칭이 발생하지 않는다. 설정해놓은 'staleTime' 만큼 시간이 흐르면 데이터는 'fresh' 한 상태에서 'stale' 한 상태로 전환된다. 이 상황에 놓여지면 다음과같은 상황에서 리페칭이 발생한다.
- 컴포넌트 마운트
- 윈도우 포커스
- 네트워크 재연결
- 수동 리패칭
당연하게도 리페칭이되면 'stale' 한 상태의 데이터가 다시 'fresh' 한 상태로 변경된다. 그리고 스크린에서 더이상 해당 데이터가 사용되지않으면(언마운트) 'inactive' 한 상태로 변경된다. 이때 캐시는 여전히 유지되고, 'gcTime' 의 타이머가 시작된다. 그리고 설정해둔 'gcTime' 이 지나면 가비지 컬렉션이 발생하며 캐시에서 완전히 제거된다. 이것이 'deleted' 다.
Mutation 과 InvalidateQuery
Mutation - 변형, 변이 돌연변이
서버의 상태를 변경하는 작업을 의미 CRUD에서 R을 제외한 create과 update 그리고 delete를 담당한다.
InvalidateQuery - 쿼리 무효화
캐싱된 쿼리를 무효화하는 메서드이다.
Mutations과 invalidateQueries는 현재 캐시된 쿼리의 상태를 변경시키기 위해 사용된다. 그리고 이때 상태를 변경하기위해서 거의 함께 사용된다.
const addTodo = useMutation({ mutationFn: (newTodo) => axios.post('/api/todos', newTodo), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }); } });
- mutationFn에서 서버로 데이터를 보내고
- 서버에서 응답이 성공적으로 오면 onSuccess 실행. ( 첫 번째 파라미터로 응답data가 전달된다.)
- 그리고 todos라는 쿼리키를 찾아 무효화 시킨다. (invalidateQueries)
- 무효화된 쿼리키는 자동으로 stale 상태(신선하지 않은 상태)로 변경되고
- 백그라운드에서 리페칭이 시작된 다음 (stale상태가되면 일어나는 일)
- 새로운 데이터로 업데이트가 되며 fresh한 상태로 바뀐다.
addTodo.mutate({ title: "새로운 할일", completed: false });
그리고 mutate는 이렇게 사용 될 것이다.
invalidateQueries vs setQueryData
invalidateQueries와 비슷한 기능의 setQueryData 메소드도 있다. 간단하게만 언급하자면,
invalidateQueries는 데이터를 무효화하고 서버에서 새로운 데이터를 가져와, 항상 서버와 동기화된 최신 데이터를 보장하지만, 추가적인 네트워크 요청이 발생하고,setQueryData는 캐시를 직접 수정하고 직접 업데이트를 하며 즉각적인 UI업데이트를 수행하지만, 서버에 추가적인 네트워크 요청을 하지 않는다. 서버상태와 불일치 가능성이 존재한다.
- invalidateQueries: 데이터 정확성이 중요할 때
- setQueryData: 즉각적인 UI 반응이 중요할 때
따라서 상황에 따라 다르며 요구사항을 고려하여 결정하면 된다.
2024.11.09 - [React-query] - react-query로 로그인 기능 구현
'React-query' 카테고리의 다른 글
react-query로 로그인 기능 구현 (0) 2024.11.09