React Query 를 통하여 서버 데이터 관리하기
들어가며
기존의 서버 데이터 관리
리액트 컴포넌트에서 상태 관리는 state와 props를 사용합니다. 컴포넌트 내부의 상태는 state, 부모로부터 받은 상태는 props로 관리합니다.
실무에서 컴포넌트를 만들고 프로젝트를 진행하다보면, 어떻게 하면 조금 더 간편하고 쉽게 상태를 관리할 수 있을까? 에 대한 고민은 늘 따라옵니다.
React 진영에서는 이 문제를 크게 두가지 방법으로 해결하고 있습니다. 바로 Context API와 Redux 입니다. 이 방법들을 통해 전역 어플리케이션의 내부상태를 보다 쉽게 관리할 수 있습니다.
그러나 프로젝트를 진행하다보면, React에서 관리해야하는 상태는 내부 상태보다 ‘서버 데이터’가 대부분을 차지하게 됩니다. 서버 데이터는 어떻게 관리하면 좋을까요? 내부 상태와는 어떻게 다르게 다루어야 할까요?
저는 최근에 새로운 프로젝트를 진행하면서, 기존에 대표적으로 사용되던 두가지 방법과 더불어 react-query를 도입해 서버 데이터를 관리했습니다. 이들을 이용해 어떻게 데이터를 관리했는지, 각 방법들은 얼마나 효율적이었는지를 이야기하려고 합니다.
Context API 에서 서버 데이터 관리하기
사실 Context API
는 일반 Component 에서 서버 데이터를 가져오는것과 크게 다를것 없습니다.
Context
Component
서버에서 데이터를 가져오고 저장하는 부분을 Context
내부에서 처리를 하는데, 이를 사용하면 서버에서 가져온 데이터를 props drilling 을 하지 않아도 되기 때문에 기본적인 Component
내부에서 직접 처리하는 방법보다는 서버에서 가져온 데이터를 컨트롤 하기 용이할것 같습니다.
그러나 데이터를 가져오는 동안에는 다양한 프로퍼티들이 필요합니다. 서버에서 데이터를 가져오는 중을 표현하기 위한 isLoading, 만약 에러가 나면 에러를 처리하기 위한 error, isError 등이 프로퍼티들을 의미합니다.
Context
그럼 사실상 isLoading, error 처리를 해주어야 하는 컴포넌트에서 Context
에 state 값들이 바뀜을 인지하고 그에 맞는 처리를 해주어야 할것입니다. 물론 Context
에서 처리할 수도 있지만 상황에 따라서는 컴포넌트에서 필요한 로직을 실행해야할 경우도 있습니다.
Component
각각의 컴포넌트마다 위와 같은 작업들을 진행해야하고 만약 다른 API를 통해서 서버에 데이터를 요청하게 된다면, 다른 수 많은 Context
를 만들어야하고 보일러플레이트 코드들을 많이 생성해야 할것입니다. 물론 많은 Context
가 생기면 불필요한 렌더링이 발생 할 수도 있고, 중첩된 Context
를 작성해야 하기 때문에 코드도 예쁘지 않을것 같습니다.
Redux 에서 서버 데이터 관리하기
React 에서 많이 사용하는 Redux
를 통하여 서버 데이터를 관리하는 법에 대하여 알아보겠습니다. (이글의 핵심은 Redux
가 아니기 때문에 가볍게 알아보고 넘어가겠습니다.)
Redux
에서는 크게 두가지 방식으로 Redux
내부에서 서버 데이터를 관리 할 수 있습니다.
- redux-thunk
- redux-saga
그 중에서 redux-thunk
를 통하여 어떻게 서버 데이터를 관리하는지 알아보겠습니다.
redux-thunk 를 통하여 서버 데이터 관리하기
다음과 같이 Redux
를 구성할 수 있고 Redux
액션 함수를 통해서 서버 데이터를 처리할 수 있습니다.
Reducer
컴포넌트 내부에서는 다음과 같이 Redux
에 비동기 액션 함수를 부를 수 있습니다
Component
Redux
를 사용하면서 Context
를 사용할때에 아쉬운 점인 중첩 컴포넌트 사용, 불필요한 리렌더링 문제가 사라졌다고 할 수 있겠지만 많은 보일러 플레이트 코드를 작성해야하는 아쉬움은 여전히 남아있습니다.
또한 서버에서 데이터를 가져올때 다양한 상태들(isLoading, error 등)을 저장하고 관리해야 하는 점도 하나의 고민거리라고 할 수 있습니다.
** 더 자세하게 redux-thunk
에 대하여 알고싶으시면 여기를 참고해주세요.
** 참고로 Redux
에서 많이 사용되는 redux-toolkit 은 redux-thunk
를 내장하고 있습니다.
기존에 서버 데이터 관리에 대한 아쉬움
위와 같이 React 진영에서 많이 사용하는 상태 관리방법을 통하여 서버 데이터를 관리하는 법에 대하여 알아보았습니다. 중복 적으로 문제가 된다고 생각하는 부분은 다음과 같습니다.
- 수많은 보일러 플레이트 코드들을 생성해야한다.
- 서버에 데이터를 가져올 때에 isLoading 상태를 관리하기 쉽지 않다.
- error 관리가 쉽지 않다.
추가로 만약 Pagination 기능이 들어간다고 생각하면 offset count, isFinished 에 대한 추가적인 상태가 더 들어가야하고, 그러면 isLoading, error 처럼 관리해야할 상태가 많아지면서 더 많은 보일러 플레이트 코드를 만들 수 밖에 없다고 생각합니다.
마지막으로 위와 같은 방법으로 서버 데이터를 가져온 뒤 한참 뒤에 그 데이터를 사용하려고 하면 서버와 sync 가 안맞을 수도 있습니다. 이때 개발자에 정확한 타이밍으로 다시 서버에서 data 를 가져와서 Redux
및 Context
에 상태를 sync 해 주어야하는데, 정확한 타이밍을 잡기란 쉽지 않습니다.
이러한 문제점을 보다 간편하게 해결할 수 있는 라이브러리가 있는데 바로 React Query
입니다.
React Query 를 통해서 서버데이터 관리하기
React Query 는 다음과 같이 자신을 설명하고 있습니다.
Performant and powerful data synchronization for React
그렇다면 얼마나 Performant 하고 powerful 한지 한번 알아보겠습니다.
React Query
를 사용하는 방법은 GraphQL
과 상당히 흡사합니다. useQuery
를 통해서 서버에 데이터를 가져올 수 있고(GET) useMutation
을 통하여 서버에 데이터를 업데이트 시킬 수 있습니다. (POST, PUT, DELTE)
App
위와 같이 root component 에서 queryClient
를 QueryClientProvider
에 넣어주고 React Query
를 사용하려는 컴포는트를 감싸주면 끝입니다. Redux
와 별반 다를것이 없습니다.
Copmonents
위의 fetchData
를 통하여 API 호출을 하게 되고 이를 useQuery
를 이용하여 key(fetchKey
)와 함께 감싸줍니다. 이 key 는 각 query 에 고유한 키값으로 React Query
는 이 key를 바탕으로 data 를 관리합니다. 단순하게 useQuery
로 감싸주게 되면 React Query
는 isLoading, isError 와 같이 현재 비동기에 대한 상태를 알려줍니다. 그에 따라 컴포넌트 내부에서 손쉽게 status 를 관리 할 수 있게 됩니다.
기존에 Context API
, Redux
를 통해서 관리해야하였던 isLoading, isError 와 같은 부수적인 프로퍼티들을 관리 하지 않아도 되고, useEffect
를 통해서 콜 하였던 비동기 함수 역시 React Query
옵션에 따라서 특정 시점에 함수를 콜 할 수도 있습니다. (다양한 옵션은 여기서 확인 하실 수 있고 이어서 조금 더 자세히 언급하도록 하겠습니다.)
React Query
는 앞서 언급한 고유한 key로 상태를 관리하게 되는데, key는 단순한 String 형태가 아니라 Array 형태로도 받을 수 있는데, 각 Array에 요소가 같다면 같은 key 로 취급하게 됩니다. 참고
또한 React Query
는 강력한 cache 기능을 가지고 있습니다. 따라서 컴포넌트가 렌더링 될 때마다 API 를 요청하는것이 아니라 cache time
을 조정하여 API 를 요청하지 않고 기존에 데이터가 있다면 기존에 데이터를 반환하여 불필요한 API 요청을 줄이게 됩니다.
마지막으로 React Query
는 강력하게 SSR 을 지원합니다. 특히 Next JS
와 아주 좋은 궁합을 가지고 있습니다. 저희는 Next JS
를 사용하여 프로젝트를 구성하였기 때문에 React Query
와 Next JS
이점을 잘 살릴 수 있었습니다.
그렇다면 실제로 어떻게 사용 하였는지 예제를 보면서 설명해보겠습니다.
강력한 devtools
React Query
에 대하여 살펴보기 전에 먼저 devtools 에 대하여 알아보겠습니다. React Query
에는 Redux devtools
에 버금갈만 한 강력한 devtools 가 존재합니다. 사용법도 간단하고 React Query
를 설치하면 자동으로 설치 됩니다.
다음과 같이 ReactQueryDevtools
만 넣어주면 끝입니다. 또한 내부적으로 process.env.NODE_ENV
값을 확인 한 후 보여주고 있기 때문에 별다른 설정이 필요가 없습니다. React Query devtools
를 사용하면 각 쿼리 키에 대한 데이터의 상태를 쉽게 볼 수 있습니다.
- 더 자세하게
React Query devtools
에 대하여 알고 싶으면 여기를 참고하시면 됩니다.
이제 실제 React Query
를 어떻게 적용 하였는지 알아보도록 하겠습니다. 앞으로 언급되는 코드는 실제 저희에 프로젝트 일부의 코드를 발췌하여 예를 들었습니다.
서버 데이터 가져오기
다음 코드는 홈 화면 데이터를 가지고 와서 홈 화면을 그리는 코드입니다.
컴포넌트가 렌더링 되면 useQuery
를 통하여 data 를 가져오게 됩니다. 각 Query 는 유니크한 key 를 갖게 되고 동일한 key 로 요청을 했을때에 Query options
에 따라서 data 를 서버에 요청하여 가져오거나 React Query
캐시 에서 가져오게 됩니다. 또한 서버 데이터를 가져올때 앞서 고려 했었던 상태 (IsLoading, isError 등) 에 대하여도 React Query
가 자동으로 제공해 줍니다. 따라서 개발자 입장에서는 불필요한 상태 관리 코드를 작성하지 않아도 되고, 필요한 타이밍에 컴포넌트 내부에서 상태를 아주 쉽게 관리할 수 있게 됩니다.
앞서 언급하였던 강력한 기능 중 하나인 Query options
에 대하여 자주 사용하는 기능에 대해서만 알아보겠습니다.
- 자세한
Query options
는 여기서 확인할 수 있습니다.
React Query
는 데이터에 상태를 5가지로 나타냅니다.
- fresh - 만료되지 않은 쿼리. 컴포넌트가 마운트, 업데이트되어도 데이터를 다시 요청하지 않는다
- fetching - 요청 중
- stale - 만료된 쿼리. 컴포넌트가 마운트, 업데이트되면 데이터를 다시 요청한다.
- inactive - 사용하지 않는 쿼리. 일정 시간이 지나면 가비지 컬렉터가 캐시에서 제거한다
여기서 staleTime
옵션을 설정해두면 staleTime
이 지난 뒤에 데이터에 상태가 stale
이 되고 컴포넌트가 마운트, 업데이트 되면 다시 데이터를 요청하여 서버 데이터와 sync (서버에 데이터 재요청)하게 됩니다.
다시 코드로 돌아가서 현재 staleTime
을 30분으로 설정해두었기 때문에 30분 동안은 컴포넌트가 마운트, 업데이트 되어도 서버에 재 요청을 하지 않게 됩니다.
뿐만 아니라 refetchOnWindowFocus
를 통해서 window focus 될 때마다 불필요한 요청을 하지 않게 만들 수 있고, retry
옵션을 통하여 만약 API요청이 실패 하였을 때에 내부적으로 다시 요청을 몇번이나 진행 해야 하는지 지정할 수 있습니다.
- 더 자세하게
useQuery
에 대하여 알고 싶으면 여기를 참고하시면 됩니다.
서버 데이터 업데이트하기
GET에 관하여 useQuery
에 대하여 알아 보았습니다. 다음은 POST, PUT, DELETE를 담당하는 useMutation
에 대하여 알아 보겠습니다.
다음 코드는 장바구니에서 옵션 수량 변경을 하는 코드입니다.
useMutation
을 사용하여 POST API를 요청하게 됩니다. 위의 코드에서 updateOptionCountMutation
에 mutate
메소드를 이용하여 parameter를 넘기게 되면 variables
에 그 parameter 가 넘어가게 됩니다. API요청을 한 뒤 onMutate
함수가 불리우게 됩니다.
onMutate
에서는 사용자에게 바로 상품 갯수가 변경되는것을 보여주기 위하여 'cart'
키를 이용하여 기존에 Query 데이터
를 가져오고 새로운 데이터를 setQueryData
를 통하여 업데이트 해줍니다. 그러면 실제로 다른 컴포넌트 에서 'cart'
키로 데이터를 가져오는 컴포넌트가 있다면 데이터가 업데이트 되고 컴포넌트가 리렌더링 됩니다. 그럼 실제로 API응답이 오기전에 화면은 사용자 인터렉션에 따라 바뀌게 됩니다.
API 에 대한 응답이 오고 상황에 따라 onError
, onSucess
가 불리우게 됩니다. onError
가 불리는 경우 error
parameter에 에러에 대한 값이 들어오고 onSuccess
가 불리는 경우 data
parameter에 API 에 대한 응답이 들어오게 됩니다.
각각의 variables
는 기존에 mutate
메소드에서 넣어주었던 parameter, context
는 onMutate
함수에서 리턴한 context
가 들어오게 됩니다. (여기서는 'cart'
키로 기존에 가져왔던 Query 데이터
)
따라서 보기 쉽게 정리하자면 다음과 같습니다.
사용자 인터렉션(장바구니 갯수 변경)
-> POST API 요청
-> onMutate 실행
-> UX를 이용하여 먼저 'cart' 키에 대한 Query 데이터 변경
-> 다른 컴포넌트에서도 'cart' 키에 대한 Query 가 업데이트 됨
-> Error 발생
-> API 요청전에 값으로 'cart' 키에 대한 Query 데이터 roll back
-> Error modal 띄워줌
-> 성공
-> 'cart' 키를 invalid 시켜줌
-> 다른 컴포넌트에서 useQuery('cart', () => API 요청)가 GET 요청을 하여 서버와 sync
queryClient
를 통해서 특정 쿼리키에 대한 값을 바꿔주거나(setQueryData
), 쿼리키에 대한 값을 무효화 시키면(invalidateQueries
), 그 쿼리키를 듣고 있는 쿼리들이 다시 불리게 되고(refetch
) data 가 sync 되고 컴포넌트 들이 그에 맞게 리렌더링 됩니다. 앞서 언급 하였던 React Query
에 목적과 부합되는 부분입니다.
Performant and powerful data synchronization for React
만약 이 부분을 서두에 언급 하였던 기존에 상태관리 방식(Context API
, Redux
) 로 구현하게 된다면 에러가 발생하였을 때 롤백 코드를 구현해야하는 함수를 구현해야합니다.
만약 성공하게 된다 하더라도 어느 시점에 다시 서버와 데이터를 sync 해주어야 하는지 고민이 될것 같습니다. 오랫동안 sync 를 해주지 않으면 서버와 데이터가 많이 차이가 나게 되고 의도치 않은 버그가 발생할 수도 있을것 같습니다.
또한 이런 코드들이 보일러 플레이트 코드가 되어 개발자들을 더 괴롭힐것 같습니다. 저희 FitPet 내부에서는 Error 발생시 해야할 작업, 성공 시 해야할 작업과 같이 중복되는 코드들을 유틸성 함수 로 감싸주어서 개발자로 하여금 callback 함수를 받게 만들어 useMutation
에 동작을 특정 지어서 상당수의 보일러플레이트 코드들을 줄여서 사용중입니다.
- 더 자세하게
useMutation
에 대하여 알고 싶으면 여기를 참고하시면 됩니다.
Pagination 처리하기
Pagination 처리도 React Query
를 통하여 쉽게 구현할 수 있습니다. 앞서 언급했던 isLoading, error 처리 뿐만 아니라 Pagination 처리에 필요한 offset 관리 등 다양한 부분이 useInfiniteQuery
에서 제공됩니다.
이런식으로 useInfiniteQuery
사용하면 기존에 useQuery
와 비슷한 return 값을 반환하는것을 볼 수 있습니다. (data, isLoading, isError, error 등을 의미합니다.) 몇가지 특이한 점은 hasNextPage
, isFetchingNextPage
, fetchNextPage
등 입니다.
다음 페이지가 있는지 확인하기
먼저 hasNextPage
다음에 요청할 page가 있는지 확인하는 boolean 값입니다. 이는 getNextPageParam
에서 undefined가 return이 되지 않으면 hasNextPage
는 true가 됩니다.
위의 코드에서 getNextPageParam
함수에 lastPage
에 sectionItems.length
값이 DEFAULT_LIMIT
보다 작은 경우 undefined를 return 하여 더이상 요청할 다음 페이지가 없다고 React query
에게 알려줍니다. 또한 getNextPageParam
에서 값을 return하면 React query
는 그 숫자를 context.pageParam
에 넣어줍니다. 그러면 자연스럽게 Pagination을 쉽게 구성할 수 있습니다.
getNextPageParam
함수에 대하여 더 알아보자면 lastPage
는 현재 요청한 API 에 대한 응답이 들어있고 allPages
는 그동안 요청하였던 API에 대한 응답이 누적되어있습니다. 따라서 allPages
에서 받아온 items 에 length를 가지고 다음 요청에 대한 offset을 정의할 수 있습니다.
페이지 데이터 가져오는 중
isFetchingNextPage
는 이름 그대로 fetch 중인지를 판단하는 함수입니다. 이를 통하여 데이터를 가져올때 손쉽게 isLoading 상태를 표시할 수 있습니다.
다음 페이지 데이터 요청하기
fetchNextPage
는 useInfiniteQuery
에서 제공되는 함수로, 앞서 언급하였던 getNextPageParam
에서 return 값이 있는 경우, 그 값이 context.pageParam
에 들어가고 자동으로 다음 offset 에 값으로 설정할 수 있습니다.
이에 따라 컴포넌트 는 단순하게 fetchNextPage
함수만 불러주면 되고 그 외에 값인 offset, 다음 요청이 필요한지 여부는 알아서 판단 해줍니다.
주의사항
한가지 주의할 점은 useInfiniteQuery
에 data Type
입니다. data 는 단순히 API 응답에 대한 값이 들어가있지 않습니다. 실제 정의된 타입을 보면 다음과 같습니다.
위의 타입이 data 에 대한 타입이고, API에 대한 응답에 값이 pages 배열에 하나씩 쌓이게 됩니다. 따라서 리스트를 구성하려면 다른 로직이 필요합니다. FitPet 에 경우 API 응답 (TData) 가 다음과 같았습니다.
실제 리스트를 그릴때에는 Product
를 배열로 가지고 있는 데이터만 필요했기 때문에 flatMap 을 통하여 Product
을 가지고 있는 배열로 만들어주어야 했습니다. 참고로 pageParams
는 위의 getNextPageParam
에서 return한 context.pageParam
들이 누적된 값입니다.
저는 useInfiniteQuery
가 정말 강력한 기능 중 하나라고 생각이듭니다. 이유는 실제로 Redux
, Context API
를 통해서 Pagination 을 구현하였을 때 해야할일들이 정말 많았기 때문입니다. isLoading, error 처리는 기본이고 그에 맞는 offset 처리, isLast 처리가 추가로 들어가면서 복잡성을 더 높였다고 생각합니다.
또한 offset 관리를 해주는 로직을 제대로 구성하지 못하면 다양한 버그가 발생하기도 하였습니다. (같은 리스트를 중복으로 가져오는 경우) 이러한 문제점을 React Query
는 아주 단순한 방법으로 잘 해결해주었다고 생각합니다.
- 더 자세하게
useInfiniteQuery
에 대하여 알고 싶으면 여기를 참고하시면 됩니다.
결론
React Query
는 앞서 언급하였던 쿼리들 말고도 useQueries
, useIsFetching
와 같은 다양한 쿼리들을 제공합니다 그 뿐만 아니라 QueryClient
라는 객체를 통하여 다양한 방법으로 쿼리를 만들 수도 있습니다. 실제 핏펫에서는 QueryClient
를 사용하여 전역으로 핏펫몰 상품을 관리 할 수 있는 커스텀 쿼리를 만들기도 하였습니다. 앞서 살짝 언급하였던 SSR 관련된 부분도 놀랄만큼 아주 손쉽게 적용할 수 있습니다. 그 외에 강력한 기능들은 여기를 참고하시면 됩니다.
Redux
, Context API
를 통해서 서버 데이터를 관리 할 때 작성해야했던 상태 플래그 역시 React Query
에서 제공해주기 때문에 수많은 보일러 플레이트 코드들을 작성하지 않아도 됩니다. 또한 의미 없이 isLoading, error 와 같은 데이터들을 따로 보관하고 있지 않아도 되고 컴포넌트에서 다시 참조하여 그에 맞는 대응을 해주어야 하는 난해한 코드를 구성하지 않아도 됩니다.
그렇지만 React Query
가 만능 해결책이라고 생각하지는 않습니다. 기존에 상태관리 방법으로 서버 데이터를 관리하는 방법은 조금 난해하고 복잡한 코드를 구성해야 하는 부분을 React Query
는 대부분 해소해주었다고 생각합니다. 그러나 프로젝트를 진행하게 되면 서버에 데이터가 아닌 다른 데이터들을 전역으로 사용할때가 있다고 생각합니다. 예를 들어 저희 핏펫 앱에서 강아지/고양이 토글 값은 어떤 API를 요청할때 많이 사용됩니다. 이때 강아지/고양이 값을 저희는 Context API
를 이용하여 저장합니다.
마찬가지로 어떤 툴이나 컨셉은 적재적소에 맞는것이 있다고 생각합니다. 그 적재적소에 타이밍을 맞춰가며 좋은 툴이나 컨셉을 적용해서 좋은 제품을 만드는것이 개발자들에 일이라고 생각합니다.
혹시 저와 같이 data fetch에 대한 고민을 하셨던 분이 있으시다면 이번 기회에 React Query
를 적용해보는것도 추천 드립니다. (참고로 이와 비슷한 SWR 도 있습니다.)