POS 프로그램을 리액트를 사용해 만들면서 파이어베이스의 데이터를 관리하는 도구로 react-query를 사용했다. 그 과정에서 배운 것들과 내가 직접 사용한 경험을 정리해보려 한다.
공식문서에 의하면, react-query는 웹 애플리케이션에서 데이터를 패치(fetch)하고 캐싱하고 서버의 상태를 동기화 및 업데이트 하는 작업을 돕는 도구이다.
(TanStack Query (FKA React Query) is often described as the missing data-fetching library for web applications, but in more technical terms, it makes fetching, caching, synchronizing and updating server state in your web applications a breeze.)
https://tanstack.com/query/latest/docs/framework/react/overview
useQuery 훅은 react-query의 가장 핵심적인 훅이다. useQuery는 2개의 필수 파라미터와 1개의 선택 파라미터를 입력받는다.
const {data,refetch,isSuccess...} = useQuery<string>('queryKey',fetchData,{staleTime : Infinity});
파라미터1. query key : react-query는 불러온 데이터를 caching해서 불필요한 네트워크 요청을 반복하지 않고 데이터를 관리할 수 있다. 그 때 각 데이터를 관리할 때 사용하는 key가 query key이다. 단일 문자열 형태로 입력할 수 있고 ['data','1']과 같이 문자열로 이루어진 배열을 입력할 수 있다.
파라미터2. fetch function : 데이터를 불러오는 함수를 입력받는다. promise를 반환하는 모든 함수를 사용할 수 있다.
파라미터3. options : useQuery에 대한 옵션을 설정할 수 있다. 자세한 내용은 아래에서 알아보려 한다.
const {data} = useQuery({queryKey:['key'],queryFn:getData});
자세한 내용은 공식문서에서 확인할 수 있다.
기존에 useQuery 없이 데이터를 불러올 땐 아래와 같은 방법으로 데이터를 불러와야 한다.
const fetchFunction =async()=> {
try {
const response = await fetch('api');
const data = await response.json();
return data;
} catch(error) {
console.error('error',error);
}
}
function Component() {
const [data,setData] = useState();
useEffect(()=>{
setData(fetchFunction());
},[])
}
react-query를 사용하면 아래와 같이 데이터를 불러올 수 있다.
const fetchFunction =async()=> {
try {
const response = await fetch('api');
const data = await response.json();
return data;
} catch(error) {
console.error('error',error);
}
}
function Component() {
const {data} = useQuery({queryKey:['data'],queryFn:fetchFunction});
}
useQuery의 세번째 파라미터인 options 객체를 통해 데이터를 관리하는 설정을 변경할 수 있다.
여기에도 다양한 옵션들이 있지만, 이 글에는 내가 많이 사용한 옵션에 대해서만 적어보려 한다.
useQuery는 불러온 데이터를 캐싱해두었다가 재사용함으로써 반복적인 네트워크 요청을 하지 않도록 도와준다. 하지만 데이터마다 업데이트되어야 하는 주기가 다를 수 있다.
만약 5분에 한번씩 업데이트 되는 데이터가 있다면 캐싱된 데이터 역시 5분에 한번 업데이트 될 필요가 있다. 이를 설정하는 것이 staleTime이다.
staleTime의 기본값은 0으로 별도의 설정을 하지 않는다면 해당 데이터는 컴포넌트가 리렌더링 될 때 마다 업데이트된다.
만약 데이터가 자동으로 업데이트되는 것을 막고 싶다면 staleTime을 Infinity로 설정하면 된다.
내가 개발한 POS프로그램의 경우, 상품 데이터는 새로운 상품을 등록하거나 상품을 수정할 때 외에는 데이터가 변경될 일이 없다. 그렇기 때문에 상품 데이터를 불러오는 useQuery의 옵션에 staleTime을 Infinity로 설정했고, useMutation을 통해 데이터를 변경할 때 onSuccess 옵션에 queryClient.InvalidateQueries를 통해 해당 쿼리를 초기화하도록 설정했다. (이 내용에 대해선 밑에 정리할 예정)
cacheTime은 해당 useQuery instance를 사용하지 않았을 때 캐시에 유지되는 시간을 설정한다. 여기서 설정한 시간이 지나면 캐싱되어 있던 데이터가 삭제되기 때문에 다시 해당 instance를 사용하게 되면 데이터를 새로 불러오게 된다. 이 역시 Infinity로 설정해서 한번 캐싱된 데이터를 계속해서 유지할 수 있다.
쿼리작업이 성공했을 때 실행할 콜백함수를 설정할 수 있다. 예를 들어 불러온 데이터를 어떤 조건에 따라 정렬하고 싶다면 아래처럼 설정할 수 있다.
const fetchFunction =async()=> {
try {
const response = await fetch('api');
const data = await response.json();
return data;
} catch(error) {
console.error('error',error);
}
}
interface IData {
number : number,
name : string,
}
function component() {
const [sortedData,setSortedData] = useState();
const {data} = useQuery<IData[]>('data',fetchFunction,{
onSuccess : ()=> {
setSortedData([...data].sort((a,b)=>a.number - b.number));
}
})
}
이렇게 하면 useQuery를 통해 불러온 데이터를 정렬해서 sortedData 상태에 저장할 수 있다.
useQuery에 대하여 onSuccess가 제거되었다. 상태를 생성하고 onSuccess에 이 상태를 설정하는 방식은 예상치 못한 결과가 나타날 수 있기 때문에 useEffect를 사용하는 것을 권장한다고 한다.
해당 쿼리의 활성화 여부를 결정할 수 있다. 만약 특정 조건에 해당할 때만 어떤 데이터를 불러오고 싶다면 이 옵션을 통해 설정할 수 있다.
예를 들어 특정 요일에만 필요한 데이터가 있다고 가정하면, 아래와 같이 설정할 수 있다.
function component() {
const {data} = useQuery('data',fetchFunction,{
enabled: new Date().getDay() === 4,
})
}
내가 처음 사용한 방식은, staleTime과 cacheTime을 아직 모를 때 불러온 데이터를 자동으로 업데이트하지 않도록 하기 위해 사용했었다.
function Component() {
const queryClient = useQueryclient();
const existingData = queryclient.getQueryData('data');
const {data} = useQuery('data',fetchData,{
enabled : !!existingData,
})
}
이와 같은 방식으로도 불러온 데이터를 자동으로 업데이트하지 않도록 할 수 있다. 하지만 문제점이 있다면, 이렇게 enabled가 false로 바뀐 쿼리에 대해선 queryClient.invalidateQueries를 통한 쿼리 초기화가 작동하지 않아서 수동으로 데이터를 업데이트하는 것 또한 불가능하다는 것이다.
그렇기 때문에 위와 같은 목적으로 사용할 땐 enabled가 아닌 staleTime을 사용하는 것이 좋다.
하지만 staleTime을 사용하는 편이 더 깔끔하다고 생각이 들어 앞으로 계속 staleTime을 사용할 것 같다.
POS프로그램엔 상품 데이터와 카테고리 데이터가 있는데, 상품을 카테고리 모드로 진열할 때 상품이 속해있는 카테고리만을 진열하기 때문에 카테고리는 상품 데이터에게 종속성을 갖는다.
먼저 가져온 상품에서 진열상태가 true인 상품만을 필터링해서 displayingProducts 상태로 관리하고 가져온 카테고리 중 이 displayingProducts가 속해있는 카테고리만을 필터링해서 categoriesContainsProducts 상태로 관리했다.
displayingProducts 상태를 업데이트 하는 기능을 products 쿼리의 onSuccess 콜백 함수에 입력했고, categoriesContainsProducts 상태를 업데이트 하는 기능을 categories 쿼리의 onSuccess 콜백 함수에 입력했다.
하지만 정상적으로 상품이 렌더링되지 않았는데, 이는 두개의 쿼리가 순차적으로 불러와지지 않기 때문이었다.
products 쿼리가 완료된 이후에 categories 쿼리가 완료되어야 했는데, categories 쿼리의 enabled 설정에 !!products를 통해 이를 구현했고 상품이 정상적으로 렌더링 되면서 문제가 해결되었다.
const [categoriesContainsProduct, setCategoriesContainsProduct] = useState<ICategory[]>([]);
const [displayingProducts, setDisplayingProducts] = useState<IProduct[]>([]);
const { data: products, isLoading: productIsLoading } = useQuery<IProduct[]>('products', () => getProducts(uid), {
onSuccess: (data) => {
setDisplayingProducts(data.filter((product) => product.display));
},
});
const { data: categories, isLoading: categoryIsLoading } = useQuery<ICategory[]>(
'categories',
() => getCategories(uid),
{
enabled : !!products, // 문제 해결
onSuccess: (data) => {
const sortedCategory =
data
?.filter((category) => displayingProducts?.some((product) => category.number === product.category))
.sort((a, b) => a.number - b.number) ?? [];
setCategoriesContainsProduct(sortedCategory);
},
},
);
onSuccess가 제거되었기 때문에 useEffect를 사용해야 한다. enabled는 여전히 지원하기 때문에 쿼리를 순차적으로 가져오기 위해선 enabled를 사용한다.
쿼리를 통해 전달받은 데이터
쿼리의 상태를 나타내는 boolean 값
쿼리를 수동으로 업데이트하는 함수
useQueryClient 훅은 캐싱된 데이터를 관리하고 쿼리를 실행하는 역할을 하는 queryClient를 반환한다.
queryClient에 저장된 모든 쿼리를 초기화하는 메서드이다. 사용자가 로그아웃한다면 모든 데이터를 초기화해야하기 때문에 로그아웃 함수에 queryClient.clear를 추가했다.
queryClient에 캐싱된 데이터를 쿼리 키를 통해 불러온다.
위에 useQuery options 중 enabled의 예제에서 사용한 것과 같이 데이터가 캐싱되어 있는지 확인할 때 사용할 수 있다.
나는 처음엔 자동 업데이트가 필요없는 데이터를 사용할 때 getQueryData를 통해 데이터를 불러왔었는데 이러한 데이터는 queryClient의 invalidateQueries를 통해 초기화된 쿼리가 업데이트 되지 않는 문제가 있어서 수정하게 됐다.
입력한 쿼리 키에 해당하는 쿼리 데이터를 invalidate하고 refetch한다. 옵션을 통해 active한 쿼리와 inactive한 쿼리, 두가지 쿼리 모두 선택해서 invalidate할 수 있다.
await queryClient.invalidateQueries(
{
queryKey: ['posts'],
exact,
refetchType: 'active',
},
{ throwOnError, cancelRefetch },
)
특정 쿼리 키에 해당하는 쿼리에 대해 default 옵션을 설정할 수 있다. 이를 통해 설정된 쿼리는 다른 컴포넌트에서 불러 올 때에도 적용되기 때문에 그 다음에 사용할 땐 별도의 설정 없이 useQuery({queryKey : ['쿼리 키']})를 통해서 해당 쿼리를 불러올 수 있다.
queryClient.setQueryDefaults(['posts'], { queryFn: fetchPosts })
function Component() {
const { data } = useQuery({ queryKey: ['posts'] })
}
쿼리 키와 상관 없이 모든 데이터를 불러올 때 default 옵션을 설정할 수 있다. root 컴포넌트에서 이를 실행하면 모든 컴포넌트에서 실행하는 쿼리에 대한 설정을 지정할 수 있다.
queryClient.setDefaultOptions({
queries: {
staleTime: Infinity,
},
})
useQuery가 데이터를 불러올 때 사용하는 훅이라면 useMutation은 데이터를 추가하거나 업데이트, 삭제와 같이 데이터를 변형할 때 사용하는 훅이다.
첫번째 파라미터로는 데이터 변형에 사용되는 함수를 입력받고 두번째 파라미터로는 mutation할 때 적용할 옵션을 객체로 입력받는다.
데이터 변형에 사용되는 함수의 파라미터는 객체 형태로 입력되어야 한다.
// wrong
const wrongFunction =async(uid:string,number:number)=>{};
// right
const rightFunction =async({uid,number}:{uid:string,number:number})=>{};
mutation이 실행될 때 실행할 콜백함수를 입력받는다. 이는 optimistic updates(낙관적 업데이트)를 위한 옵션이다.
mutation의 실패 여부와 관계없이 실행되기 때문에 mutation의 결과와 관계없는 함수를 실행하거나, 만약 관계있는 함수를 실행한다면 onError 옵션에 이를 되돌리는 함수를 설정하는 방법으로 해결할 수 있다.
mutation이 성공했을 때 실행할 콜백함수를 입력받는다. 나의 경우 상품을 추가하거나 상품정보를 업데이트할 때 mutation을 사용했고, 상품정보 데이터들은 staleTime을 Infinity로 설정했기 때문에 수동적으로 업데이트가 필요했고, queryClient.invalidateQueries를 통해 서버로부터 데이터를 업데이트 하도록 설정했다.
mutation이 실패했을 때 실행할 콜백함수를 입력받는다.
mutation을 실행하는 그룹을 설정한다. 그룹 내에 있는 mutation은 순차적으로 실행된다. 기본값은 서로 유니크한 키값이 설정되어 있기 때문에 별도의 설정이 없다면 모든 mutation이 동시에 진행된다.
useMutation으로 return되는 객체에는 useQuery와 유사한 프로퍼티와 메서드들이 있다.
useMutation을 통해 반환된 객체에서 mutate 메서드를 사용하면 등록된 mutation 작업이 실행된다.
const mutation = useMutation(updateChangedData, {
onSuccess: () => {
queryClient.invalidateQueries('products');
},
});
mutation.mutate('updateChangedData의 파라미터');
useQuery와 마찬가지로 mutation 작업의 상태를 나타낸다.
출처 : react-query 공식문서