[ React Query ] 우아한 테크 세미나를 통해 리엑트 쿼리와 상태관리에 대해서 공부하기
공부 레퍼런스
우아한 테크 세미나 영상을 통해 React Query , React Query를 이용한 상태관리에 대해서 공부를 진행했습니다.
세미나에서 다룬 내용을 공부하였으며 세미나 발표 내용 그대로 정리하였습니다.
세미나 영상 바로가기 : https://youtu.be/MArE6Hy371c
공부의 목적은 React Query의 사용법 보다는 사용하는 이유에 대해서 초점이 맞춰져 있습니다.
React Query 를 알아보기 앞서서 우선 상태에 대해서 언급합니다.
상태란?
주어진 시간에 대해 시스템을 나타내는 것으로 언제든지 변경될 수 있습니다.
즉 문자열, 배열, 객체 등의 형태로 응용 프로그램에 저장된 데이터를 말합니다.
-> 쉽게말해서 관리해야하는 데이터들의 상태를 말합니다.
상태관리는?
프로덕트가 커짐에 따라서 상태 관리에 대한 어려움도 커져 이를 쉽게 해결하기 위한 방법들이 매번 고안되고 있습니다.
React는 단반향 바인딩이므로 Props Drilling 과 같은 이슈를 해결하기 위해 Context Api, Redux 나 MobX 와 같은 라이브러리를 활용해서 해결하는것과 같이 말이죠.
* Prop Drilling : React Component 에서 다른 컴포넌트 또는 부분으로 데이터를 전달하는 과정을 말합니다.
서버와 클라이언트 state 관리를 분리
보통 store에 전역 상태관리를 하게되는데,
서버 상태관리를 store 내부에 하게 되어 이게 상태를 관리하는 것인지,
API 서버 통신을 하는 곳인지 목적성이 뚜렷하지 않게되는 문제가 발생합니다.
React Query
React Query 라는 라이브러리를 사용하면 위의 문제를 해결할 수 있습니다.
공식문서를 살펴보면, 리엑트 쿼리를 다음과 같이 설명하고 있습니다.
0. Performant and powerful data synchronization for React
: React를 위한 강력하고 성능 좋은 데이터 동기화
1. Declarative & Automatic
: 선언적이고 자동이다.
- 리엑트 쿼리는 데이터를 가져올 위치( url ) 를 알려주고 나머지는 자동이며,
따로 설정이 필요없이 즉시 캐싱, 백그라운드 업데이트 및 오래된 데이터를 처리합니다.
2. Simple & Familiar
: 간단하고 친숙하다.
- promise 또는 async / await 작업방식만 알고 있다면 React Query 를 사용하는 방법을 이미 알고 있을것입니다.
관리할 시스템을 이해하고 무거운 구성(설정) 할 필요가 없습니다.
리엑트 쿼리는 기록만 해주고 클라이언트는 데이터를 해결하는 함수를 전달하기만 하면 됩니다.
3. Powerful & Configurable
: 강력하고 구성(설정) 가능하다.
- 기본적으로 전용 devtools을 제공해주고, 무한 로딩 API 및 데이터 업데이트를 쉽게 만드는 도구도 제공해줍니다.
React Query core 컨셉
Queries / Mutations / Query Invalidation
1. Queries ?
간단한 용도 설명 : 데이터 Fetching용
import { useQuery } from 'react-query';
function App(){
const userInfo = useQuery('user', fetchUserData);
// 'user' : query key
// fetchuserData : query function
}
기본적으로 Query Key, Query Function 으로 구성이 됩니다.
Query Key
- React Query 는 Query Key에 따라 쿼리 캐싱을 관리합니다.
String 형태
// query key => ['user']
useQuery('user', function);
Array 형태
// query key => ['user', 1]
useQuery(['user', 1], function)
// query key => ['user', 1, { type:'corp' }]
useQuery(['user', 1, { type:'corp' }], function)
Query Function
promise 를 반환하는 함수를 작성을합니다.
데이터를 resolve하거나 error을 throw 하는 ajax, fetch, axios, etc 등등......
그렇다면 useQuery가 반환하는 객체는?
... 제공하는 인터페이스들이 굉장히 많습니다.
// useQuery 반환값
const {
data,
dataUpdatedAt,
error,
errorUpdatedAt,
failureCOunt,
isError,
isFetched,
isFetchedAfterMount,
isFetching,
isIdle,
isLoading,
isLoadingError,
isPlaceholderData,
isPreviousData,
isRefetchError,
isRefetching,
isStale,
isSuccess,
refetch,
remove,
status
} = useQuery(queryKey, queryFunction);
많이 사용 될 인터페이스 및 간단한 설명
data : 마지막으로 성공한 resolved된 데이터 ( Response )
error : 에러가 발생했을 때 반환되는 객체
isFetching : Request한 데이터가 수신 되고 있을때 ( in-flight ) true
status, isLoading, isSuccess, isLoading 등등 : 현재 query의 상태 반환
refetch : 해당 query refetch 하는 함수 제공
remove : 해당 query cache에서 지우는 함수 제공
useQuery Options
리엑트 쿼리 useQuery에서 옵션들을 설정해줄 수 있습니다.
useQuery(queryKey, queryFunction, queryOptions)
// queryOptions 부분처럼 세번째 인자에 옵션값을 설정해줍니다.
그렇다면 옵션들은 뭐가 있을까요?
useQuery(queryKey, queryFunction, {
cacheTime,
enabled,
initialData,
initialDataUpdatedAt,
isDataEqual,
keepPreviousData,
meta,
notifyOnChangeProps,
notifyOnChangePropsExclusions,
onError,
onSettled,
onSuccess,
placeholderData,
queryKeyHashFn,
refetchInterval,
refetchIntervalInBackground,
refetchOnMount,
refetchOnReconnect,
refetchOnWIndowFocus,
retry,
retryOnMount,
retryDelay,
select,
staleTime,
structuralSharing,
suspense,
useErrorBoundary
})
많이 사용 될 인터페이스 및 간단한 설명
onSuccess, onError, onSettled : query fetching 성공/ 실패/ 완료 시 실행할 기능( Side Effect ) 정의
enabled : 자동으로 query를 실행시킬지 말지 여부
retry : query 동작 실패 시, 자동으로 retry 할지 결정하는 옵션
select : 성공 시 가져온 data를 가공해서 전달
keepPreviousData : 새롭게 fetching 시 이전 데이터 유지 여부
refetchInterval : 주기적으로 refetch 할지 결정하는 옵션
React Query 예제
다음은 React Query 공식 홈페이지에서 제공해주는 제일 기본이 되는 query의 예제 코드입니다.
/* eslint-disable jsx-a11y/anchor-is-valid */
import React from "react";
import ReactDOM from "react-dom";
import { QueryClient, QueryClientProvider, useQuery } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools";
import axios from "axios";
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Example />
</QueryClientProvider>
);
}
function Example() {
const { isLoading, error, data, isFetching } = useQuery("repoData", () =>
axios.get(
"https://api.github.com/repos/tannerlinsley/react-query"
).then((res) => res.data)
);
if (isLoading) return "Loading...";
if (error) return "An error has occurred: " + error.message;
return (
<div>
<h1>{data.name}</h1>
<p>{data.description}</p>
<strong>👀 {data.subscribers_count}</strong>{" "}
<strong>✨ {data.stargazers_count}</strong>{" "}
<strong>🍴 {data.forks_count}</strong>
<div>{isFetching ? "Updating..." : ""}</div>
<ReactQueryDevtools initialIsOpen />
</div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
모든 페이지를 감싸는 App에서 <QueryClientProvider> 컴포넌트로 페이지를 감싸고,
컴포넌트의 client property에 QueryClient 클래스를 전달해주어 React Query가 전역적으로 접근이 가능하게 합니다.
그리고 각 페이지 또는 컴포넌트에서 useQuery, queryKey, queryFunction, queryOptions 으로 데이터를 패칭하고 사용합니다.
! queries 파일을 분리하는 방법을 추천 !
개발을 하는 단계에서는 불필요한 코드를 최대한 줄이고 확장성 좋은 코드를 작성해야 할 것입니다.
세미나에서 추천하는 방법으로는 모두가 공감 할만하게 도메인별 queries 파일을 분리하고
import 해와서 사용하는 방법을 추천하고 있습니다.
다음은 예시입니다.
// Query 선언부
export const useFetchUser = (type, options) => {
return useQuery('fetchUser', fetchUser(type), options);
}
// Components
const fetchUserResult = useFetchUser(type, {
onSuccess: data => {
// onSuccess 로직 작성
},
onError: error => {
// onError 로직 작성
}
});
고민점
우아한 기술 블로그 댓글 중 발췌된 질문을 세미나에서 다루었습니다.
문제는 다음과 같았습니다.
<문제>
1. 마운트시 데이터 패치되지 않고 버튼을 클릭했을 때 데이터를 패치 받고
데이터에 따라 history.push를 통해 라우팅 해야하는 상황
<방법론>
1) enabled를 false로 두고 이벤트 핸들러에서 refetch() 로 패치하는 방법
2) enabled 옵션에 해당하는 상태를 useState로 컴포넌트내에 두고,
이벤트 핸들러에서 해당 상태 값을 변경하여 enabled를 조건부로 만족시켜 패치시키는 방법
해당 댓글 작성자는 React Query 제작자들은 선언적이라는 이유로 2)을 권장하지만
API가 많아지면 많아 질수록 관리할 state가 많아진다는 문제입니다.
세미나에서 말하는 방법에 대해서는 복잡한 로직을 작성해야하는 경우라면 2번의 방법을 추천하고 그게 아니라면 1번의 방법을 추천해주고 있습니다.
개인적으로는 2번의 방법이 state 상태를 통해서 enabled를 관리해주어서 정확한 코드의 목적성을 확인하기 편하다고 생각이 듭니다.
또한 모든 코드는 하나의 방법이 정답이다라는 것은 없기에 상황에 따라서 더 적용하기 수월하고 이해하기 쉬운 코드를 작성하는것이 정답이라는 생각입니다.
해당 코드를 직접 작성을 해보아서 서로의 특성을 이해해 보았습니다.
enabled
enabled의 true/false 여부에 따라 useQuery를 선언한 컴포넌트가 로드 되었을때 자동적으로 useQuery를 사용할 것인가 아닌가를 결정하게 됩니다
1. refetch를 통해 버튼 클릭 API 통신
- 쿼리의 queryOption 중 enabled 를 false로 설정합니다.
- 버튼 클릭시 refetch 메서드를 실행시켜줍니다.
import { useQuery } from "react-query";
async function getUserDatafetch() {
return fetch("https://jikor1st.github.io/FakeApi/userData/user.json")
.then((res) => {
return res.json();
})
.then((data) => {
return data;
});
}
export function ReactQueryEnabledTypeOne() {
const fetchUser = useQuery("fetchUser", getUserDatafetch, {
enabled: false,
});
const { isSuccess, isLoading, isError, data, refetch } = fetchUser ?? {};
function handleGetUser() {
// refetch 메서드를 통해서 react query를 리패치합니다.
refetch();
}
if (isLoading) return "로딩중....";
if (isError) return "에러 발생....";
return (
<Container>
<InfoHeader>유저 정보 enable refetch</InfoHeader>
<RequestBtn onClick={handleGetUser}>불러오기</RequestBtn>
{isSuccess ? (
<>
<UserInfo>이름 : {data.user_name}</UserInfo>
<UserInfo>이메일 : {data.user_email}</UserInfo>
<UserInfo>나이 : {data.user_age}</UserInfo>
</>
) : null}
</Container>
);
}
2. state 상태로 버튼 클릭 API 호출
state 상태 관리를 통해 리 렌더링을 통해 enabled를 관리합니다.
import { useQuery } from "react-query";
async function getUserDatafetch() {
return fetch("https://jikor1st.github.io/FakeApi/userData/user.json")
.then((res) => {
return res.json();
})
.then((data) => {
return data;
});
}
export function ReactQueryEnabledTypeTwo() {
const [enabled, setEnabled] = useState(false);
const fetchUser = useQuery("fetchUser2", getUserDatafetch, {
// enabled 를 상태로 관리합니다.
enabled: enabled,
});
const { isSuccess, isLoading, isError, data } = fetchUser ?? {};
function handleGetUser() {
// 상태를 업데이트하면 enabled가 상태에 따라 리렌더링이 되며 데이터를
// 패치하는 상태가 됩니다.
setEnabled(true);
}
if (isLoading) return "로딩중....";
if (isError) return "에러 발생....";
return (
<Container>
<InfoHeader>유저 정보 enable useState</InfoHeader>
<RequestBtn onClick={handleGetUser}>불러오기</RequestBtn>
{isSuccess ? (
<>
<UserInfo>이름 : {data.user_name}</UserInfo>
<UserInfo>이메일 : {data.user_email}</UserInfo>
<UserInfo>나이 : {data.user_age}</UserInfo>
</>
) : null}
</Container>
);
}
2. Mutations
간단한 용도 설명 : 데이터 updating 시 사용
- 데이터 생성 / 수정 / 삭제 ( CRUD )
useQuery 보다 더 심플하게 Promise 반환 함수를 인자로 넣으면 됩니다.
( Query Key를 넣게되면 react query devtools에서 볼 수 있어 디버깅 하기 편하다고합니다. )
const mutation = useMutation(() => {
return "promise 반환 함수"
})
그렇다면 useMutation이 반환하는 객체는?
const {
data,
error,
isError,
isIdle,
isLoading,
isPaused,
isSuccess,
mutate,
mutateAsync,
reset,
status
} = useMutation(.....)
많이 사용 될 인터페이스 및 간단한 설명
mutate : mutations을 실행하는 함수
-> useMutation은 useQuery와는 다르게 선언되어도 사용하기 위해서는 실행함수를 실행시켜야합니다.
mutateAsync : mutate와 비슷 하지만, Promise 반환
reset : mutation 내부 상태 clean
... 나머진 useQuery의 인터페이스와 사용법이 거의 유사합니다.
useMutation Options
마찬가지로 useMutation 에서 옵션들을 설정해줄 수 있습니다.
{} = useMutation(mutationFunction, {
mutationKey,
onError,
onMutate,
onSettled,
onSuccess,
retry,
retryDelay,
useErrorBoundary,
meta
})
onMutate : 본격적인 Mutation 동작 전에 먼저 동작하는 함수,
Optimistic update 적용할 때 유용하다고 합니다.
* optimistic update란?
낙관적 업데이트로 서버 업데이트시에 UI에서도 업데이트를 할것이라는 가정으로
미리 UI를 업데이트 시켜주고 서버를 통해 검증을 받고 업데이트/롤백 하는 방식을 말합니다.
-> 롤백 시 onError을 통해...
... 나머지는 useQuery 와 유사합니다.
3. Query Invalidation
해당 Key를 가진 query는 stale 취급되고, 현재 rendering 되고 있는 query들은 백그라운드에서 refetch 됩니다.
한마디로 cache 되어 있는 데이터들을 invalidate 하여( 무효로 하다 ) 다시 refetch 시킬 수 있습니다.
사용법은 queryClient를 통해 invalidate 메소드를 호출
// Invalitate every query in the cache
queryClient.invalidateQueries()
// Invalidate every query with a key that starts with `todos`
queryClient.invalidateQueries('todos')
이상으로 Data fetching 과 updating 하는 방법을 보았는데,
어떻게 리엑트 쿼리에서는 캐싱과 동기화 처리를 하게되는 걸까요?
Caching 과 Synchronization ( 캐싱 / 동기화 )
react query 는 서버 상태관리를 하는데, 다음과 같은 일을 처리합니다.
- 데이터 가져오기
- 캐시
- 동기화
- 데이터 업데이트
여기서 캐시랑 동기화는 도대체 어떻게 처리하게 되는걸까요
HTTP 리소스 명세에서는 다음과 같이 설명한다고 합니다.
RFC 5861
HTTP Cache-Control Extensions for Stale Content
stale-while-revalidate
-> 백그라운드에서 stale response를 revalidate 하는 동안 캐시가 가진 stale response를 반환
무슨 말일까요?
- 캐시된 응답이 오래되었는지 아닌지에 대한 재검증 process 입니다.
stale-while-revalidate 를 포함한 Cache-Control 응답 헤더는 max-age 또한 포함하며,
해당 max-age에 지정된 시간(초)를 통해서 max-age 보다 새로운 캐시된 응답은 최신것으로 간주,
더 오래된 캐시된 응답은 오래된 것으로 간주되게 됩니다.
Cache-Control: max-age=600, stale-while-revalidate=30
/*
한마디로 정리하자면 해당 캐시는 max-age를 통해 600초의 유효기간을 가지며,
stale-while-revalidate 를 통해 비동기적으로 백그라운드에서 새로운 것으로 요청하는 동안
클라이언트가 최신이 아닌 응답을 받아 들일 것을 정의해줍니다.
그래서 30초가 지나게 되면 클라이언트에 새로운 요청 값을 다시 할당 시켜 캐싱해줍니다.
*/
이렇게 동작하면 Latency가 숨겨진다는데 Latency란 지연 시간을 의미합니다.
지연시간을 최대한 숨긴다는 뜻으로 받아들여집니다.
이 컨셉을 React-Query를 통해 메모리 캐시에 적용을 옵션을 통해 할 수 있습니다.
cacheTime : 메모리에 얼마만큼 있을 것인지, 해당 시간 이후에는 GC( Garbage collector )에 의해 처리가 되며 default 값은 5분입니다.
staleTime : 얼마의 시간이 흐른 후에 데이터를 stale( 신선하지 않은 -> 최신 상태가 아닌 ) 취급할 것인지에 해당하는 옵션입니다.
default 값은 0입니다.
-> 영상에서 설명하기를 위의 해당하는 두 값은 주문에 관련되거나 항상 최신상태를 유지해야하는 곳에서는 사용하지 않는게 좋다고합니다.
refetchOnMount , refetchOnWindowFocus , refetchOnReconnect
-> 옵션이 true 이면 Mount( mount되었을 때 ), window focus( window에 포커스가 갔을 때 ), reconnect( 다시 네트워크에 연결되었을 때 ) 시점에 data 가 stale 이라고 판단이 되면 refetch 됩니다 ( 모두 default 값은 true 입니다. )
React Query 상태 흐름
1. fetching : staleTime이 0이 되면 stale 단계로 ( active 상태의 query )
-> staleTime > 0 이면 fresh 단계로
2. fresh : staileTime이 만료되기 전까지 fresh ( active 상태의 query )
-> staileTime이 만료되면 stale 단계로
3. stale : 스크린에서 사용되는 동안 stale ( active 상태의 query )
/ refetch 이벤트가 발생하거나 Mount, window focus등의
옵션에 따른 트리거 발생하면 fetching 단계로
-> 스크린에서 사용 안하면 inactive 단계로
4. inactive : 스크린에서 사용 안하고, cacheTime이 만료되기 전까지 inactive
/ 다시 화면에 나타나면 상황에 따라 active 상태의 query 단계로 이동
-> cacheTime 이 만료되면 deleted 단계로
5. deleted : 스크린에서 사용 안하고 캐시타임이 만료되면,
GC( Garbage Collection )가 처리하면서 deleted
React Query "Zero-Config"의 default 값을 통한 역할
staleTime -> default 값 0 : Queries 에서 cached data는 언제나 stale 취급됩니다.refetchOnMount / refetchOnWindowFocus / refetchOnReconnect -> default 값 true : 각 시점에서 data가 stale 상태라면 항상 refetch가 발생됩니다.cacheTime -> default 값 60 * 5 * 1000 초 즉 5분 : inactive 쿼리들은 5분 뒤 GC에 의해 처리됩니다.retry -> default 값 3 : Query 요청 실패 시 3번까지 요청 재시도를 합니다.
React Query는 어떻게 서버 상태 관리를 하고 접근하게 해줄까?
QueryClient 내부적으로 React의 Context API를 사용하여 Provider을 통해
각 쿼리에 해당하는 server state에 접근할 수 있습니다.
React Query를 쓰면 좋은점.
- 서버 상태( server state ) 관리에 용이하고 Redux, MobX등 상태관리 라이브러리를 사용할 때보다 직관적은 API 호출 코드를 작성할 수 있습니다.
- API 처리에 관련해서 각종 인터페이스 및 옵션을 제공해줘서 유동적인 상태관리 및 처리를 할 수 있게 해줍니다.
- 클라이언트 스토어( client store ) 가 Front End에서 필요한 전역상태만 남아서 서로 분리되고 직관적인 Store 답게 사용할 수 있습니다. ( Boilerplate 코드가 매우 감소된다고합니다. )
- 라이브러리 자체에서 제공해주는 devtool을 통해 원할한 디버깅을 할 수 있습니다.
- Cache 전략이 필요할 때 라이브러리가 알아서 처리를 해주기 때문에 아주 좋습니다.
React Query의 최근 근황 ( 2022.02 기준)
1년 전 (2022.02) 보다 2022.02가 약 4배 많은 npm 다운로드 수를 기록하고 있다고합니다.
마치며... React Query를 추천할 때
- 수많은 전역 상태가 API 통신과 무분별하게 엮어있어 복잡하고 비대해진 Store 관리를 고민할때
- API 통신 관련 코드를 간단히 구현하고 재사용하고 싶을때
- Front End에서 데이터 Caching 전략에 대해서 고민할 때
참고
우아한 테크 세미나 : React Query와 상태관리
stale-while-revalidate