React Query 시작하기

JU CHEOLJIN·2022년 6월 2일
5

React

목록 보기
15/15
post-thumbnail

들어가며

기업협업을 진행하던 당시에 면접을 보고 온 동기가 useEffect 대신할 수 있는 라이브러리가 있다는 말을 했다. 그 당시에는 무엇인지 제대로 몰랐지만 얼마 후 그 라이브러리가 React Query 라는 것을 알게 됐다. 물론, useEffect를 대신하는 라이브러리라고 보기에는 어렵지만 useEffect를 이용해 데이터를 가져오지 않아도 되는 것은 맞았다. 이후 우아한 테크세미나를 통해 React Query에 대해서 발표를 듣기도 했지만 간단한 테스트 정도만 진행해보고 제대로 도입을 고민해보지는 못했다. 하지만, 데이터를 잘 다루기 위해 Redux를 사용하고, 비동기를 잘 처리하기 위해 여러가지 미들웨어까지 도입하고 있자면 절실히 다른 방법을 찾고 싶은 생각이 들곤 했다. 상태 관리 라이브러리라는 이름과는 다르게 Redux에는 온갖 비동기 관련 로직들이 가득해지곤 했으니까. 

아래는 React Query 공식 사이트에서 볼 수 있는 React Query의 소개 문구이다. 

Fetch, cache and update data in your React and React Native applications all without touching any "global state".

즉, 데이터를 가져오고, 캐시하고, 동기화하고 업데이트 하는 작업을 전역 State 없이 수월하게 할 수 있도록 해주는 라이브러리이다.

도입하게 된 계기

우선, React Query의 특징을 정리해보면 이렇다.

1. Server StateClient State라는 개념을 분리하여 프론트엔드에서 Server State를 다루는 방법을 재정의하고 있다. 

2. Server State를 다루는 방법에 있어 SWR(Stale-While-Revalidate) 전략을 통해 해결하고 있다. 

3. useQuery와 같은 Hook 인터페이스로 제공되기 때문에 도입이 어렵지 않고 기존 방식보다도 간결하게 사용할 수 있다.

React Query가 좋다는 건 알겠는데, 그래서 굳이 기존의 방식을 벗어나 새롭게 도입해야하는 이유는 무엇일까?

우선, 나는 기존에 Server State와 Client State를 혼용하며 관리했다. 특히 Server State의 경우에는 클라이언트가 아닌 서버에서 관리되고 변경되는 상태였기 때문에 언제나 "out of date" 될 수 있는 위험이 있었다. 그렇기 때문에 지속적으로 데이터 업데이트 및 관리, 캐싱 등에 대해서 고민을 할 필요가 있었다. 이를 해결하기 위해 Redux 등을 사용하면 무수한 미들웨어와 많은 보일러 플레이트 코드가 따라오곤 했다. 

심지어, 서버의 데이터를 가져오고 업데이트 하는 과정에서 사용자의 UX를 위해 로딩 등의 상태를 처리하기 위해서 추가적으로 Client State 를 늘려야만 했다. 아래처럼.

const App = () => {
  const [isLoading, setIsLoading] = useState();
  
  
  const fetchData = async () => {
  	setIsLoading(true) // 로딩 상태로 변경하고
  
  
    try {
      const data = await apiFunction()
      
      if(data) {
      	setIsLoading(false); // 로딩을 끝낸다.
        // ... 이후 로직
      }
    }
    catch(error) {
    	console.log(error);
        setIsLoading(false);
    }
  }
}


export default App;

페이지가 늘어날 수록, 기능이 많아질 수록 Loading 등과 관련 된 상태를 개발자가 직접 구현하고 핸들링 해야하는 부담은 커지게 된다. 이는 휴먼 에러가 생길 가능성이 늘어난다는 말과 같다. 실제로 개발을 진행하며 여러가지 상태를 관리하는 과정에서 로딩 관련 상태를 놓치는 경우가 생겨 무한 로딩을 하게 되는 경우가 발생했다. 

이러한 고민들을 React Query는 각종 보일러 플레이트나 러닝 커브 없이 기존에 하던 방식과 유사하고 더욱 간결하게 해결할 수 있도록 해준다. React Query과 어떻게 이를 해결하고 있는지 살펴보자. 

 사용 방법

간단한 예시를 통해서 리액트 쿼리의 사용 방법을 알아보자.  

설치법

npm i react-query // npm 사용

yarn add react-query // yarn 사용

초기 설정

import {QueryClientProvider, QueryClient} from "react-query";

const queryClient = new QueryClient();

function App() {
  return (
  	<QueryClientProvider client={queryClient}>
  		{...}
  	</QueryClientProvider>);
}

React Query는 데이터를 전역 상태처럼 사용할 수 있도록 제공하고 있기 때문에 이를 위해 Provider로 전체 앱을 감싸줘야 한다. 이후에는 사용하고자 하는 곳에서 useQuery를 통해 데이터를 받아올 수 있다. 

useQuery

데이터를 가져오는 경우에는 useQuery hook을 사용하면 된다. useQuery는 아래와 같은 구조를 가지고 있다. 

// 일반적
const responseData = useQuery(쿼리 키, 비동기 함수, 옵션);

// 구조분해 할당
const {data, isLoading, isFetching} = useQuery(쿼리 키, 비동기 함수, 옵션);

쿼리 키의 경우에는 string이나 배열로 전달할 수 있고 배열로 전달하는 경우에는 ["key", "first"], ["key", "second"] 와 같이 "key" 가 동일하다고 해도 다른 쿼리로 인식된다. 더 확실한 관리를 위해서 배열의 형태로 전달하는 것을 추천한다. 

실제 사용법은 아래와 같다. 실제로 이번 스프린트에 사용되는 코드에 적용된 모습을 수정했다.

const useListQuery = (options?: any) => {
    const { openSuccessToast, openErrorToast } = useToast();

    const queryClient = useQueryClient();

    const { data, isLoading } = useQuery(["list"], getList, {
        onError: () => {
            openErrorToast({});
        },
        
        onSuccess: () => {
        	openSuccessToast({});
        },

        refetchOnWindowFocus: false,
    });

    const invalidateClientListQuery = () => {
        queryClient.invalidateQueries('list');
    };

    return {
       data,
       isLoading,
    };
};

export default useListQuery;

React Query의 TkDodo는 쿼리를 컴포넌트 옆에서 관리하자고 제안했다. (링크 : Effective React Query Keys) 폴더 구조까지 적용하지는 않더라도 커스텀 훅으로 분리하여 쿼리를 관리하는 부분은 컴포넌트와 데이터 통신의 유착을 줄이고 유지보수에도 도움이 될 것이라고 생각한다.  위의 코드에서 알 수 있듯이 isLoading이나 isFetching 등의 상태를 제공하고 있기 때문에 추가로 상태를 만들어 관리할 필요가 없으며 데이터를 위한 상태를 만들 필요도 없다. data를 이용해 바로 사용하면 된다! 

만약 서버로 받은 데이터를 가공해야 한다면? options 중에서 select를 사용할 수 있다. 

const useCountryListQuery = (lang: string) => {
    const queryClient = useQueryClient();

    const { data } = useQuery(
        ['countryList', lang],
        () => getCountryList(lang),
        {
            select: res => {
                return {
                    ...res,
                    data: res.data.body.map(item => {
                        return {
                            ...item,
                            label: item.name,
                            value: item.name,
                        };
                    }),
                };
            },
            refetchInterval: 60 * 60 * 1000,

            refetchOnWindowFocus: false,
        }
    );

    const invalidateCountryListQuery = () => {
        queryClient.invalidateQueries('countryList');
    };

    return {
        countryList: data?.data,
        invalidateCountryListQuery,
    };
};

export default useCountryListQuery;

위의 코드처럼 사용하면 데이터를 가공하여 관리할 수 있다. 한 가지 주의할 점은 onError, onSuccess, select의 경우에 enabled 등의 옵션과 상관 없이 useQuery가 호출되는 경우 무조건적으로 호출된다는 점이다. 처음에는 이를 몰랐고 select 안에서 사용되는 modal이 여러 번 반복되는 현상을 겪게 됐다. API 호출은 1번만 정상적으로 이뤄지고 있었기 때문에 다른 원인이라 생각해서 한참을 헤매고 나서야 이유를 알 수 있었다.

useQuery의 여러 옵션은 공식문서 를 통해 볼 수 있지만 몇가지 중요한 옵션은 짚고 넘어가고자 한다. 

✅ cacheTime : 어느 시점까지 메모리에 데이터를 저장해 캐싱할 것인지를 결정하는 옵션이다. 기본값은 5분이다. (단위 ms)
✅ staleTime : React Query에서는 데이터를 요청한 쿼리를 stale하다고 판단하는데 이를 결정하는 시간을 결정하는 옵션이다. 기본 값은 0이다.
(✨ cacheTime 이나 staleTime 둘 중에 하나라도 만료가 되면 데이터를 재요청하게 된다.)
✅ refetchOnMount : 컴포넌트가 마운트 되는 경우에 새로운 데이터를 가져온다. 기본값 true이다.
✅ enabled : false로 설정할 경우 컴포넌트가 마운트 되어도 데이터를 가져오지 않는다. 기본값은 true이다.
(주로 동기적으로 데이터를 가져오고 싶은 경우에 사용할 수 있다.)
✅ refetchOnWindowFocus : 브라우저로 포커스가 옮겨진 경우에 새로운 데이터를 가져온다. 기본값은 true이다.
(웹뷰 등을 사용한다면 이를 이용해 사용성을 개선할 수 있다.)

여기서 cacheTimestaleTime 의 경우에는 React Query를 사용할 때 중요한 옵션이라고 생각한다. 캐싱 관련 기능을 손쉽게 사용할 수 있도록 도와주는 옵션들이기 때문에 이를 이용해 효율적으로 Server State를 관리할 수 있다. 

useMutation

서버의 데이터를 업데이트하는 작업을 진행하는 경우에는 useMutation 훅을 사용할 수 있다. 

const { mutate: addList, isLoading } = useMutation(addData, {
	onSuccess: () => {
		invalidateListQuery();
		openSuccessToast({ message: '저장되었습니다.' });
    },
	onError: () => {
		openErrorToast({});
    },
});

const handleConfirm = async () => {
	addList(data);
}

useMutation 역시 isLoading 이나 isError 등의 반환 값을 받아 처리할 수 있으며 추가로 mutate 메서드를 받을 수 있다. 이를 이용해서 서버의 데이터를 업데이트 할 수 있다. 이 때, 서버의 데이터를 업데이트 한 이후에 캐싱된 데이터도 새롭게 업데이트를 해야 하는데 invalidateQueries 메서드를 통해서 쉽게 해결할 수 있다. 

// 어떤 키도 전달하지 않으면 캐시된 모든 쿼리를 무효화 할 수 있다.
queryClient.invalidateQueries()

// 키를 전달한 경우에는 해당 키를 가진 쿼리를 무효화 한다.
queryClient.invalidateQueries('list')

추가로 onMutate 옵션을 통해서 mutate 함수가 실행되기 전에 필요한 로직을 작성할 수도 있다. 

마무리

가장 최근에 사용했던 코드에 React Query를 적용해보면서 굉장히 많은 상태를 줄일 수 있다는 점을 느꼈다. 특히, 간단하게 사용을 해봤음에도 캐싱이나 여러가지 상태를 제공해주는 것의 강력함을 느낄 수 있었다. 특히, 기존의 많은 통신들을 React Query를 통해서 개선할 수 있다면 Redux store의 영역을 줄이고 modal의 open/close 의 상태나 다른 UI 관련 상태에만 집중할 수 있어 더욱 깔끔하게 코드를 개선할 수 있겠다는 생각이 들었다. 물론, 도입을 해보면서 Api 핸들링 부분이 기존과 맞지 않는 부분이 있어 어려움이 있었고 persist 기능 등 더욱 살펴볼 지점은 분명히 있었지만 매우 매력적인 도구라는 것은 분명했다. 다음에는 React Query를 프로젝트에 적용하면서 겪었던 어려움을 해결하는 과정에 대해서 기록해보고자 한다.

profile
사회에 도움이 되는 것은 꿈, 바로 옆의 도움이 되는 것은 평생 목표인 개발자.

0개의 댓글