🔥 React Query를 상태 관리자로 활용하기
강의 목차
React Query는 React 애플리케이션에서 데이터 가져오기를 대폭 단순화한 도구로 많은 개발자들의 사랑을 받고 있습니다. 하지만 놀랍게도 React Query는 실제로 데이터 가져오기 라이브러리가 아닙니다.
React Query는 데이터를 직접 가져오지 않습니다. 오직 소수의 기능만이 네트워크와 직접 연관되어 있습니다. 예를 들어 OnlineManager, refetchOnReconnect
, 오프라인 뮤테이션 재시도 등이 있죠. 이는 첫 queryFn
을 작성할 때 더욱 분명해집니다. 실제로 데이터를 가져오기 위해서는 fetch, axios, ky, 심지어 graphql-request 같은 도구를 사용해야 합니다.
그렇다면 React Query가 데이터 가져오기 라이브러리가 아니라면, 정확히 무엇일까요?
비동기 상태 관리자
React Query는 비동기 상태 관리자입니다. 모든 형태의 비동기 상태를 관리할 수 있으며, Promise만 받으면 됩니다. 대부분의 경우 데이터 가져오기를 통해 Promise를 생성하기 때문에 이 부분에서 빛을 발합니다. 하지만 React Query는 단순히 로딩 상태와 오류 상태를 처리하는 것 이상의 기능을 합니다. 이는 진정한 "전역 상태 관리자"입니다. QueryKey
가 쿼리를 고유하게 식별하므로, 같은 키로 두 곳에서 쿼리를 호출하면 동일한 데이터를 얻게 됩니다. 이는 사용자 정의 훅으로 가장 잘 추상화할 수 있어, 실제 데이터 가져오기 함수에 두 번 접근할 필요가 없습니다:
export const useTodos = () => useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) function ComponentOne() { const { data } = useTodos() } function ComponentTwo() { // ✅ ComponentOne과 정확히 같은 데이터를 얻게 됩니다 const { data } = useTodos() } const queryClient = new QueryClient() function App() { return ( <QueryClientProvider client={queryClient}> <ComponentOne /> <ComponentTwo /> </QueryClientProvider> ) }
javascript
이 컴포넌트들은 컴포넌트 트리의 어디에 있어도 상관없습니다. 같은 QueryClientProvider
아래에 있다면 동일한 데이터를 얻게 됩니다. React Query는 동시에 발생할 수 있는 요청도 중복 제거합니다. 따라서 위의 시나리오에서 두 컴포넌트가 같은 데이터를 요청해도 네트워크 요청은 한 번만 발생합니다.
데이터 동기화 도구
React Query는 비동기 상태(또는 데이터 가져오기 측면에서는 서버 상태)를 관리하기 때문에, 프론트엔드 애플리케이션이 데이터를 "소유"하지 않는다고 가정합니다. 이는 정확합니다. API에서 가져온 데이터를 화면에 표시할 때, 우리는 단지 그 데이터의 "스냅샷"을 보여주는 것입니다. 즉, 데이터를 가져온 시점의 버전을 보여주는 것이죠. 그렇다면 우리가 스스로에게 물어봐야 할 질문은 다음과 같습니다:
데이터를 가져온 후에도 그 데이터가 여전히 정확할까요?
이 질문의 답은 전적으로 우리가 다루는 문제 영역에 따라 다릅니다. 모든 좋아요와 댓글이 포함된 트위터 게시물을 가져온다면, 그 데이터는 아마도 매우 빠르게 오래된(낡은) 상태가 될 것입니다. 반면 매일 갱신되는 환율 데이터를 가져온다면, 데이터를 다시 가져오지 않아도 한동안은 꽤 정확할 것입니다.
React Query는 우리의 뷰를 실제 데이터 소유자인 백엔드와 '동기화'하는 수단을 제공합니다. 이 과정에서 React Query는 너무 적게 업데이트하는 것보다는 자주 업데이트하는 쪽을 선택합니다.
React Query 가 없던 시절
React Query와 같은 라이브러리가 등장하기 전에는 데이터 가져오기에 대해 두 가지 접근 방식이 꽤 일반적이었습니다:
-
한 번 가져와서 전역적으로 배포하고 거의 업데이트하지 않기
이는 제가 Redux로 많이 했던 방식입니다. 어딘가에서 데이터 가져오기를 시작하는 액션을 디스패치하는데, 보통 애플리케이션이 마운트될 때 합니다. 데이터를 받으면 전역 상태 관리자에 넣어 애플리케이션 어디서나 접근할 수 있게 합니다. 결국 많은 컴포넌트가 할 일 목록에 접근해야 하니까요. 그 데이터를 다시 가져올까요? 아니요, 이미 "다운로드"했으니 가지고 있는 거죠. 왜 다시 가져와야 할까요? 아마도 백엔드에 POST 요청을 보내면 "최신" 상태를 돌려줄 수도 있겠죠. 더 정확한 정보를 원한다면 브라우저 창을 새로고침하면 됩니다...
-
매 마운트마다 가져와서 로컬에 유지하기
때로는 데이터를 전역 상태에 넣는 것이 "과하다"고 생각할 수 있습니다. 이 모달 대화상자에서만 필요하다면 대화상자가 열릴 때 '적시에' 가져오면 되지 않을까요? 아시다시피
useEffect
, 빈 의존성 배열(eslint가 경고하면 eslint-disable을 던져주고),setLoading(true)
등을 사용하죠... 물론 이렇게 하면 데이터를 가져올 때까지 매번 대화상자가 열릴 때마다 로딩 스피너를 보여줘야 합니다. 로컬 상태가 사라졌으니 달리 무엇을 할 수 있을까요...
이 두 접근 방식 모두 최적이라고 하기 어렵습니다. 첫 번째 방식은 로컬 캐시를 충분히 자주 업데이트하지 않고, 두 번째 방식은 잠재적으로 너무 자주 다시 가져오면서도 두 번째로 가져올 때 데이터가 없어 사용자 경험이 좋지 않습니다.
그렇다면 React Query는 이런 문제를 어떻게 접근할까요?
낡은 상태로 재검증하기
이미 들어보셨을 수도 있는데, 이는 React Query가 사용하는 캐싱 메커니즘입니다. 새로운 개념이 아니며, HTTP Cache-Control Extensions for Stale Content에서 자세히 읽어볼 수 있습니다. 요약하자면, React Query는 데이터를 캐시하고 필요할 때 제공합니다. 그 데이터가 더 이상 최신 상태가 아닐(낡았을) 수 있더라도 말이죠. 이 원칙은 낡은 데이터가 데이터가 없는 것보다 낫다는 것입니다. 데이터가 없다는 것은 보통 로딩 스피너를 의미하고, 이는 사용자에게 "느리다"고 인식될 수 있기 때문입니다. 동시에 백그라운드에서 데이터를 다시 가져와 재검증을 시도합니다.
스마트한 재요청
캐시 무효화는 꽤 어려운 문제입니다. 그렇다면 언제 백엔드에 새 데이터를 요청해야 할까요? 물론 useQuery
를 호출하는 컴포넌트가 다시 렌더링될 때마다 이를 수행할 수는 없습니다. 그렇게 하면 현대의 기준으로도 너무 비용이 많이 들 것입니다.
따라서 React Query는 재요청을 트리거할 전략적인 시점을 선택합니다. 이 시점들은 "그래, 지금이 데이터를 가져올 좋은 시기야"라고 말할 수 있는 좋은 지표가 됩니다. 이는 다음과 같습니다:
-
refetchOnMount
useQuery
를 호출하는 새 컴포넌트가 마운트될 때마다 React Query는 재검증을 수행합니다. -
refetchOnWindowFocus
브라우저 탭에 포커스를 맞출 때마다 재요청이 일어납니다. 이는 제가 가장 좋아하는 재검증 시점이지만, 종종 오해를 받습니다. 개발 중에는 브라우저 탭을 자주 전환하므로 "너무 많다"고 느낄 수 있습니다. 하지만 실제 운영 환경에서는 대부분 사용자가 우리 앱을 열어둔 탭으로 돌아올 때를 의미합니다. 이메일을 확인하거나 트위터를 읽다가 돌아온 경우죠. 이런 상황에서 최신 업데이트를 보여주는 것은 매우 합리적입니다.
-
refetchOnReconnect
네트워크 연결이 끊겼다가 다시 연결되면, 이 또한 화면에 보이는 내용을 재검증할 좋은 지표가 됩니다.
마지막으로, 앱 개발자인 여러분이 좋은 시점을 알고 있다면 queryClient.invalidateQueries
를 통해 수동으로 무효화를 호출할 수 있습니다. 이는 뮤테이션을 수행한 후에 특히 유용합니다.
React Query의 마법에 맡기기
저는 이런 기본값들을 좋아하지만, 앞서 말했듯이 이들은 네트워크 요청 횟수를 최소화하는 것이 아니라 최신 상태를 유지하는 데 중점을 둡니다. 이는 주로 staleTime
이 기본적으로 _0_으로 설정되어 있기 때문입니다. 즉, 새 컴포넌트 인스턴스를 마운트할 때마다 백그라운드에서 재요청이 일어납니다. 이를 자주 수행한다면, 특히 같은 렌더 주기가 아닌 짧은 간격으로 마운트가 일어난다면 네트워크 탭에서 많은 요청을 볼 수 있습니다. 이는 React Query가 이런 상황에서 중복 제거를 할 수 없기 때문입니다:
function ComponentOne() { const { data } = useTodos() if (data) { // ⚠️ 조건부로 마운트됩니다, 데이터가 있을 때만 return <ComponentTwo /> } return <Loading /> } function ComponentTwo() { // ⚠️ 따라서 두 번째 네트워크 요청을 트리거합니다 const { data } = useTodos() } const queryClient = new QueryClient() function App() { return ( <QueryClientProvider client={queryClient}> <ComponentOne /> </QueryClientProvider> ) }
javascript
"뭐지, 방금 2초 전에 데이터를 가져왔는데 왜 또 네트워크 요청이 발생하는 거지? 이건 말도 안 돼!"
- React Query를 처음 사용할 때 이런 반응을 보이는 것은 당연합니다
이 시점에서 데이터를 props로 전달하거나, prop 드릴링을 피하기 위해 React Context
에 넣거나, 이 모든 요청이 너무 많다고 생각되어 refetchOnMount
/ refetchOnWindowFocus
플래그를 끄고 싶을 수 있습니다.
일반적으로 데이터를 props로 전달하는 것은 문제가 없습니다. 가장 명시적인 방법이며 위의 예제에서도 잘 작동할 것입니다. 하지만 더 현실적인 상황으로 예제를 조금 바꿔볼까요?
function ComponentOne() { const { data } = useTodos() const [showMore, toggleShowMore] = React.useReducer( (value) => !value, false ) // 네, 오류 처리는 생략했습니다. 이건 "단지" 예제일 뿐이니까요 if (!data) { return <Loading /> } return ( <div> 할 일 개수: {data.length} <button onClick={toggleShowMore}>더 보기</button> // ✅ 버튼을 클릭한 후에 ComponentTwo를 보여줍니다 {showMore ? <ComponentTwo /> : null} </div> ) }
javascript
이 예제에서 두 번째 컴포넌트(할 일 데이터에도 의존하는)는 사용자가 버튼을 클릭한 후에만 마운트됩니다. 이제 사용자가 몇 분 후에 그 버튼을 클릭한다고 상상해보세요. 이 상황에서 백그라운드 재요청이 있다면 할 일 목록의 최신 값을 볼 수 있지 않을까요?
React Query가 하고자 하는 일을 우회하는 앞서 언급한 접근 방식 중 어느 것을 선택했다면 이는 불가능할 것입니다.
그렇다면 어떻게 하면 이 모든 장점을 누리면서도 문제를 해결할 수 있을까요?
staleTime 맞춤 설정하기
아마도 여러분은 이미 제가 가고자 하는 방향을 짐작하셨을 것입니다: 해결책은 특정 사용 사례에 맞게 staleTime
을 설정하는 것입니다. 알아야 할 핵심은 다음과 같습니다:
데이터가 신선한 동안은 항상 캐시에서만 가져옵니다. 신선한 데이터에 대해서는 얼마나 자주 가져오려고 하든 네트워크 요청을 보지 않을 것입니다.
staleTime
에 대한 "정확한" 값은 없습니다. 많은 상황에서 기본값이 정말 잘 작동합니다. 개인적으로는 최소 20초로 설정하여 그 시간 동안 요청을 중복 제거하는 것을 선호하지만, 이는 전적으로 여러분의 선택입니다.
보너스: setQueryDefaults 사용하기
v3부터 React Query는 QueryClient.setQueryDefaults를 통해 Query Key별로 기본값을 설정하는 훌륭한 방법을 지원합니다. 따라서 #8: 효과적인 React Query 키에서 설명한 패턴을 따른다면, 원하는 수준의 세분화로 기본값을 설정할 수 있습니다. setQueryDefaults
에 Query Key를 전달하는 것은 Query Filters와 같은 표준 부분 일치를 따르기 때문입니다:
const queryClient = new QueryClient({ defaultOptions: { queries: { // ✅ 전역적으로 20초로 기본 설정 staleTime: 1000 * 20, }, }, }) // 🚀 할 일 관련 모든 것은 // 1분의 staleTime을 가집니다 queryClient.setQueryDefaults( todoKeys.all, { staleTime: 1000 * 60 } )
javascript
관심사의 분리에 대한 참고사항
앱의 모든 계층의 컴포넌트에 useQuery
와 같은 훅을 추가하는 것이 컴포넌트의 책임을 혼합한다는 우려가 있을 수 있습니다. 옛날에는 "스마트 vs 덤", "컨테이너 vs 프레젠테이션" 컴포넌트 패턴이 보편적이었습니다. 이는 명확한 분리, 분리, 재사용성, 테스트 용이성을 약속했습니다. 프레젠테이션 컴포넌트는 "그저 props를 받기만" 하니까요. 하지만 이는 많은 prop 드릴링, 보일러플레이트, 정적 타이핑이 어려운 패턴(👋 고차 컴포넌트)과 임의적인 컴포넌트 분할로 이어졌습니다.
훅이 등장하면서 많은 것이 변했습니다. 이제 어디서든 useContext
, useQuery
, 또는 useSelector
(Redux를 사용한다면)를 사용하여 컴포넌트에 종속성을 주입할 수 있습니다. 이렇게 하면 컴포넌트가 더 결합된다고 주장할 수 있습니다. 또는 앱 내에서 자유롭게 이동할 수 있고 자체적으로 작동하므로 더 독립적이라고 말할 수도 있습니다.
Redux 메인테이너인 Mark Erikson의 Hooks, HOCS, and Tradeoffs (⚡️) / React Boston 2019 영상을 꼭 추천합니다.
요약하자면, 모든 것은 트레이드오프입니다. 공짜 점심은 없습니다. 한 상황에서 잘 작동하는 것이 다른 상황에서는 작동하지 않을 수 있습니다. 재사용 가능한 Button
컴포넌트가 데이터를 가져와야 할까요? 아마도 아닐 것입니다. Dashboard
를 DashboardView
와 데이터를 전달하는 DashboardContainer
로 분할하는 것이 의미가 있을까요? 마찬가지로 아마도 아닐 것입니다. 따라서 우리는 트레이드오프를 알고 올바른 상황에 올바른 도구를 적용하는 것이 중요합니다.
결론
React Query는 여러분이 허용한다면 앱에서 비동기 상태를 전역적으로 관리하는 데 탁월합니다. 사용 사례에 맞지 않는다고 확신하는 경우에만 재요청 플래그를 끄고, 서버 데이터를 다른 상태 관리자와 동기화하려는 충동을 참으세요. 일반적으로 staleTime
을 맞춤 설정하는 것만으로도 훌륭한 사용자 경험을 얻으면서 백그라운드 업데이트 빈도를 제어할 수 있습니다.