본 아티클은 Tkdodo의 Thinking in React Query 블로그 글을 번역한 내용입니다.
이 글은 여기서 더 멋지게 보실 수 있습니다 😆
🗣️ 오늘의 아티클은 다른 형태로 진행 됩니다. 얼마전 비엔나에서의 밋업과 예전에 React Summit에서 진행했던 발표의 슬라이드와 대본으로 이루어져 있습니다. 슬라이드를 좌우로 스와이프 하거나 양끝에 있는 화살표 버튼을 클릭하여 슬라이드를 이동할 수 있습니다. 그럼 즐겁게 읽어주세요!
안녕하세요 여러분 👋, 오늘 이 자리에 있게 되어 영광입니다. 오늘 이야기 하고자 하는 바는…여러분의 신발끈을 바로 잡는 것에 대해서 입니다.
대부분의 사람들이 신발끈을 묶는 데에 있어서 정확한 방법과 잘못된 방법의 차이를 구분해내지 못합니다. 두 방법은 언뜻 보기에 비슷해 보여도 전자의 매듭은 꽉 묶여있지만 후자의 매듭은 걸을때 풀리기 십상입니다. 이는 여러분의 삶을 변화시킬 수 있는 작은 변화일 것 입니다.
리액트 쿼리를 사용할 때도 작은 변화가 큰 차이를 만들어낼 수 있는 몇가지 상황들이 존재하곤 합니다.
제가 2020년경에 오픈소스 커뮤니티 활동을 한창 시작했을때 이러한 상황들을 발견하곤 했습니다. 여러 플랫폼들을 통해서 질의응답을 하는 활동은 제가 오픈소스 활동을 시작하게 된 아주 훌륭한 방법이였습니다. 여러분들이 이러한 문제들을 해결해주면 사람들은 고마워하고 감사해하며, 저 역시 이전에는 마주치지 못했던 문제들을 질문으로 받아보면서 많은 것들을 배워 볼 수 있었습니다.
이러한 활동 덕에 리액트 쿼리에 대해 매우 잘 알게 되었고, 그와 동시에 여러 질문들에서 공통된 패턴들을 파악할 수 있었습니다. 질문해주신 분들 중 많은 분들이 리액트 쿼리가 무엇인지 또는 무엇을 하는지에 대해 오해하고 있었고 이러한 오해는 약간의 다른 생각들을 갖도록 했습니다.
다시 돌아와서, 오늘 제가 이야기하고자 하는 바는 리액트 쿼리를 이해하는데 있어서 더 나은 사고방식을 가질 수 있는 3가지 요소들 입니다. 신발 끈을 바로 매는 것 처럼 이번 시간을 통해 여러분들은 리액트 쿼리를 쉽지만 정확하게 이해할 수 있게 될 것입니다.
자 이제 본격적으로 “React Query적으로 사고”할 수 있는 요소들에 대해 알아봅시다
리액트 쿼리가 리액트의 데이터 패칭에 있어서 숨겨진 퍼즐 조각이라고 종종 묘사되는 만큼 여러분들이 들으면 놀랄만한 사실이 있는데 그것은 바로 리액트 쿼리는 데이터 패칭 라이브러리가 아니라는 점 입니다. 리액트 쿼리는 여러분들에게 데이터 패칭 기능을 제공해 주지 않습니다. 왜 그런지는 아래 예시를 살펴보면 알 수 있습니다.
위 코드는 일반적인 useQuery
를 사용하는 예제 코드입니다. 첫번째 인자로는 리액트 쿼리가 데이터를 저장하는 데 사용하는 고유한 queryKey
를 넘겨주고 두번째 인자로는 데이터를 불러올때마다 실행되는 함수인 queryFn
을 넘겨줍니다.
우리는 이러한 훅을 컴포넌트 내부에서 이용함으로써 데이터를 렌더링하고 쿼리가 가질 수 있는 상태들을 사용할 수 있게 됩니다.
잠시 queryFn
부분을 다시 보면 axios
라는 라이브러리로 작성되어 있다라는 점을 확인할 수 있습니다. 리액트 쿼리는 데이터 패치를 어디서 어떻게 하는지 관여하지 않습니다.
리액트 쿼리가 관여하는 부분은 queryFn
의 실행 결과로 fulfilled
혹은 rejected
상태인 Promise
객체가 반환된 이후 뿐 입니다.
사실, 누군가 리액트 쿼리 메인테이너인 저에게 리액트 쿼리에 관한 이슈를 작성 했을때 API가 공개되지 않은 상태라 그 문제점을 재현할 수 없다고 하는 상황이라고 말한다면, 저는 위에 처럼 queryFn
부분을 데이터 패칭 라이브러리 없이 Promise
객체를 반환하는 함수를 이용해서 간단하게 그 문제 상황을 재현할 수 있다고 답할 것입니다. 당연하게도 axios
, fetch
, graphql-request
와 같은 데이터 패칭 라이브러리는 Promise
객체를 반환하기에 리액트 쿼리와 잘 동작합니다.
여러분들이 리액트 쿼리가 데이터 패치에는 관여하지 않는 것을 알게 되었다면, 바라건대 리액트 쿼리의 질문들 중 데이터 패치와 관련된 내용들은 없어져야 할 것이 분명합니다. 예를 들어, 아래와 같은 데이터 패치와 관련된 질문들은 모두 동일한 정답을 가질 것 입니다.
🤔 : 리액트 쿼리에서 baseURL을 어떻게 정의하나요?
🤔 : 리액트 쿼리에서 response header들은 어떻게 접근하나요?
🤔 : 리액트 쿼리에서 graphQL 요청은 어떻게 생성할 수 있나요?
🗣️ : 리액트 쿼리는 그러한 것들에 관여하지 않습니다. 그저
queryFn
의 자리에는Promise
나 반환해주세요.
그렇다면 이제 새로운 의문점을 갖게 될 수 있습니다.
🤔 : 데이터 패칭 라이브러리가 아니라면 리액트 쿼리는 도대체 무엇인가요?
이 질문에 대한 저의 답변은 항상 다음과 같았습니다.
🗣️ : 비동기 상태 관리자(Async State Manager) 입니다.
자 여기서 비동기 상태 라는 것이 무엇을 의미하는지 이해하는게 중요합니다.
리액트 쿼리의 창시자인 Tanner Linsley는 2020년 5월 경에 It’s Time to break up with your “Global State” 라는 내용으로 발표를 진행했었습니다. 위의 발표 내용이 이번 아티클과 매우 밀접하게 관련되어 있으니 꼭 한 번 시청하는 것을 권장드립니다.
위 발표 내용의 핵심은 우리가 오랜 기간 동안 “상태” 라는 것을 어디에 보관 해둘지에 대해서만 고민하는 것에 몰두했다는 것 입니다. 예를 들어, 우리는 하나의 상태가 한 컴포넌트 내부에서만 필요하다면 위의 그림처럼 컴포넌트 내부에서 지역 상태를 정의하여 사용할 것입니다.
상위 계층에서도 사용해야 한다면, 해당 상태를 부모 컴포넌트로 끌어올리고 필요한 곳에 props로 내려주는 형태로 사용할 것 입니다.
부모보다도 더 높은 계층이나 더 넓은 영역에서 상태를 사용해야 한다면 어떻게 할 수 있을까요? redux
나 zustand
와 같은 “전역 상태 관리자”에게 상태값을 위임함으로써 리액트 컴포넌트 외부에서 상태를 관리하되 컴포넌트 모든 컴포넌트들에서 해당 상태를 사용할 수 있도록 할 수 있을 것입니다.
그리고 우리는 한동안 이러한 방식을 모든 종류의 상태에 대해서 적용했습니다. 버튼을 클릭하면 테마가 전환되는데 필요한 상태부터 네트워크 상에서 불러오는 이슈의 목록이나 프로필 데이터와 같은 종류의 상태에 이르기까지 말이죠. 우린 그들을 모두 동일한 전역 상태로 취급했습니다.
이러한 사고방식은 우리가 상태를 다르게 분류하면서 부터 변화하게 됩니다. 상태가 어디에서 사용되는지가 아니라 어떠한 종류의 상태인지를 따지면서 부터 말이죠.
다크모드 토글 버튼을 클릭할때 사용되는 상태처럼 우리가 완전히 제어권을 가지고 동기적으로 동작하는 상태는 이슈들의 리스트 데이터와 같이 외부에서 통제되고 비동기적으로 동작하는 상태와는 완전히 다른 특성들을 가집니다.
비동기 상태 혹은 서버 상태를 사용할때 클라이언트는 데이터를 불러올 당시의 스냅샷만을 갖고 있게 됩니다. 클라이언트가 그 상태에 대한 제어권을 갖고 있지 않다보니 이러한 상태는 언제 든지 만료될 수 있죠. 백엔드, 정확히는 데이터베이스가 이 상태를 소유하게 됩니다. 클라이언트는 그저 그 스냅샷을 화면상에서 띄울 수 있게 빌려오는 것 뿐입니다.
사용자가 브라우저 탭을 열어놓고 30분간 다른 탭을 띄워놓다가 다시 그 탭으로 되돌아가는 것을 해보면 스냅샷을 클라이언트가 빌려온다는 개념이 크게 체감될 것 입니다. 이런 상황에서 사용자가 되돌아 왔을때 페이지 내 데이터가 자동으로 갱신되어 있다면 더 멋진 경험을 제공해주지 않을까요? 이런 자동 갱신 과정이 필요한 이유는 사용자가 페이지를 떠나있는 동안 다른 이용자가 데이터를 언제든지 바꿀 수 있기 때문입니다.
그리고 이 서버 상태는 로딩과 에러 상태에 관한 정보와 같이 메타 정보들을 관리하는 작업이 필요하기에 동기적으로는 사용할 수 없는 상태값 입니다.
그래서 데이터를 자동으로 최신화 하는 작업을 비롯해 비동기적인 라이프사이클(에러, 로딩)을 관리하는 작업들은 기존의 다목적 상태 관리자들에게 바랄 수 없는 것들 이였습니다. 그러나 이제 여러분에게는 이런 요소들을 잘 수행해주는 도구들이 갖추어졌으니 비동기 상태들을 더 잘 사용할 수 있게 되었습니다. 그저 더 알맞은 일들을 할 수 있게 적합한 도구를 사용하기만 하면 됩니다.
두번째는 상태 관리자가 무엇인지 그리고 왜 리액트 쿼리가 상태 관리자 중 하나인지를 이해하는 것입니다.
일반적으로 상태 관리자들은 앱 내에서 상태를 효율적으로 이용하게 해줍니다. 여기서 중요한 효율적 이란 용어를 다음와 같이 정의하고 싶습니다.
효율적인 업데이트 = 업데이트를 하되 너무 많이 하지 않도록 하기
너무 많은 업데이트가 문제가 되지 않지 않았다면, 우리 모두 React Context를 통해 상태 관리 하는 방식을 택했을 겁니다. 그러나 이는 문제사항으로 여겨지고 있고 대다수의 상태 관리 라이브러리들은 여러가지 방법을 통해 불필요하게 많은 업데이트가 일어나는 문제들을 해결하려고 하고 있죠.
두 개의 유명한 상태 관리 라이브러리인 Redux
와 zustand
는 selector
라는 api를 제공 하고 있습니다. 이들은 컴포넌트가 관심있어 하는 상태의 일부만 구독할 수 있게 합니다. store
내 구독하고 있지 않는 다른 부분이 업데이트 된다 하더라도 컴포넌트들은 신경쓰지 않도록 말이죠. 그리고 이 두 라이브러리은 모두 전역으로 이용가능하게 만들어 놓았기에 hook
을 호출하여 앱 내부 어디에서나 원하는 상태값에 접근할 수 있습니다.
리액트 쿼리도 이 원칙에서 크게 벗어나지 않습니다. 다만, 클라이언트가 구독하고 있는 대상이 기존의 라이브러리 와는 다르게 queryKey
라는 고유 키를 통해 구분 된다는 약간의 차이만 있을 뿐입니다.
클라이언트가 useIssue
이라는 커스텀 훅을 호출하고 있는 상황에서, QueryCache
내의 issues
라는 slice
에 변화가 생기면 해당 데이터는 업데이트가 이루어지게 됩니다.
앞의 기능들만으로 충분하지 않다면 리액트 쿼리에도 있는 selector
를 이용해볼 수 있습니다. 이를 통해 저장된 결과들 중 일부만을 도출하여 컴포넌트가 관심있는 것만 가져가는 정제화된 상태 구독도 가능합니다. 예를 들어, 위의 예시의 코드를 사용할때 클라이언트 내 컴포넌트가 이슈를 “opened”
상태에서 “closed”
상태로 바꾼다 하더라도 issue
의 길이는 변하지 않기에 useIssueCount
훅을 이용해 구독중인 컴포넌트에는 어떠한 리렌더링도 발생하지 않습니다.
그리고 다른 상태 관리자들과 마찬가지로 클라이언트는 사용하고자 하는 모든 영역에서 useQuery
를 호출함으로써 필요한 상태 데이터에 접근할 수 있게 됩니다.
위 사진에서 보이는 코드들은 지양해야될 패턴들입니다. useEffect
를 이용하거나 onSuccess
라는 콜백을 이용해 별도의 state
에 데이터를 저장해 두는 작업들 말입니다.
이러한 형태의 상태 동기화 작업은 단일 진실 공급원(Single Source Of Truth) 원칙을 위반하며 불필요한 작업이라고 여겨집니다. 리액트 쿼리가 이미 상태 관리자이기에 별도의 상태로 관리할 필요가 없기 때문이죠.
여기까지 보셨다면 여러분들은 이런 생각이 드실지 모르겠습니다. 예를 들어, 위의 그림처럼 useIssues
훅을 서로 다른 3개의 컴포넌트에서 사용한다고 합시다. 그런데 여기서 Dialog와 같이 일부 컴포넌트가 조건부로 렌더링된다고 한다면, 동일한 API endpoint에 대한 데이터가 여러번 중복해서 불러와지는 것을 보게될 것입니다.
첫번째 사진의 상황을 보고는 “2초 전에 이미 불러왔는데 왜 또 다시 불러오는 걸까??” 라고 생각할 수 있습니다. 이제 여러분은 백엔드로의 중복요청을 막아야 한다는 이유로 공식문서에 들어가 refetch
와 관련된 모든 옵션을 비활성화하게 될 수도 있습니다. 그러고는 “상태값을 리덕스에 뒀어야했나..”라고 후회하게 될지도 모르죠.
이러한 일이 벌어지는 것에는 다 이유가 있으니 잠시만 저의 이야기를 들어주시길 바랍니다. 왜 리액트 쿼리는 이렇게 많은 요청을 보내는 것일까요?
이는 비동기 상태에서 필요한 요구사항들을 수행해야 되기 때문입니다. 비동기 상태는 언제든지 만료될 수 있기에 특정 시점에서 업데이트하는 작업이 필요합니다. 리액트 쿼리에서는 이러한 업데이트 작업을 다음의 특정 상황들에서 진행 합니다.
이러한 이벤트가 일어날때마다 리액트 쿼리는 자동으로 쿼리를 다시 패치할 것 입니다. 그러나 이게 전부가 아닙니다. 더 중요한 것은 리액트 쿼리가 이러한 업데이트 작업을 모든 Querie
들에 대해서 하지는 않는 다는 것 입니다. 오로지 stale
하다고 여겨지는 Query에 대해서만 위 작업을 진행합니다. 여기서 stale
에 대한 내용은 이번 아티클에서 두번째로 중요한 내용이 될 것입니다. 바로 staleTime
여러분의 가장 친구라는 것 입니다.
리액트 쿼리는 또한 데이터 동기화 도구이기는 하나, 무자비하게 모든 쿼리의 데이터를 백그라운드 단에서 다시 불러오지는 않습니다. 다시 패치할지 말지를 결정하는 기준은 staleTime
에 의해 정해지며 여기서 staleTime
은 “데이터가 stale
해지는 데 까지 걸리는 시간”을 의미합니다.
stale
의 반댓말은 fresh
인데, 이는 fresh
하다고 여겨지는 데이터는 다시 불러오지 않고 미리 캐시된 데이터를 클라이언트에게 전달됨을 의미합니다. fresh
한 상태가 아니라면 클라이언트는 캐시된 데이터를 얻음과 동시에 다시 데이터를 불러 오는 과정이 진행됩니다.
이 말인 즉슨, 오로지 stale
한 쿼리들만이 자동으로 업데이트 되는 것을 의미합니다. 다만, staleTime
은 기본적으로 0
으로 설정됩니다. 당연히 여기서 0
이라는 숫자는 0
밀리초를 의미하며, 리액트 쿼리는 별다른 설정이 없다면 모든 쿼리를 즉시 stale
한 상태라고 여깁니다. 이러한 방식은 엄청나게 반복된 데이터 패칭을 야기할 수 있지만, 리액트 쿼리에서는 네트워크 상에서의 요청을 최소화 하지 않고 데이터를 최신 상태로 유지하는 쪽을 선택했습니다.
그래서 이제 staleTime
값은 여러분에게 주어진 리소스와 요구사항에 따라 알맞게 정의하는게 필요합니다. 또한 staleTime
값에는 정해진 정답이 있는게 아닙니다. 여러분이 생각하기에 서버가 재시작 될때에만 변화하는 게 필요하다고 생각된다면 staleTime
을 Infinity
로 설정하는 것이 가장 적합한 선택이 될 것입니다. 반면에, 협력툴과 같이 많은 사용자들이 동시에 데이터를 업데이트한다면, staleTime
을 0
으로 두는 것이 더 나은 선택이 될 것입니다.
따라서 리액트 쿼리을 활용하는데 있어서 매우 중요한 부분은 staleTime
을 알맞게 조절하는 것입니다. 다시 한번 말하지만 이는 정답이 정해져있지 않으며 제가 가장 선호하는 바는 위의 사진과 같이 전역적으로 기본값을 설정해두고 필요할때마다 덮어쓰는 방식입니다.
자, 앞서서 한번 봤던 비동기 상태에 대한 요구사항들을 다시 살펴보도록 합시다. 우리는 이제 리액트 쿼리에서는 데이터가 stale
한 상태가 되거나 위에서 언급한 이벤트들 중 하나가 발생하면 캐시를 최신화 시킨다는 사실을 알고 있습니다. 그 이벤트 중 제가 가장 강조하고 싶은 것 중 하나가 바로 QueryKey
가 변화하는 이벤트 입니다.
그럼 QueryKey
가 변화하는 이벤트는 언제 가장 많이 발생할까요? 이 질문에 대한 대답은 다음 주제로 이어집니다.
세번째 주제는 Query
의 파라미터들을 의존성으로서 봐야 한다는 것입니다. 저는 이 주제를 공식문서에도 강조하고 별도의 블로그 포스트로 올렸음에도 다시 한번 강조하고 싶습니다.
여러분이 위와 같이 filters
라는 파라미터를 정의해서 queryFn
함수에서 데이터 패치 요청시에 사용하고 있다면 여러분은 queryKey
에도 filters
라는 변수를 추가해 주어야 합니다.
이러한 규칙은 리액트 쿼리가 잘 작동해주는 것을 보장해줍니다. Query 내 엔트리들이 그들의 입력값(예시에서는 filters
)에 따라 별도로 캐싱되도록 합니다. 그래서 다른 필터값을 가지게 되면 캐시 상에서 다른 키 값으로 저장되도록 하여 Race Condition 문제를 피하도록 해줍니다.
또한 이러한 방식은 filters
값이 변화했을때 자동으로 데이터를 다시 불러오도록 해주는데 이는 이전 cache 엔트리에서 다른 엔트리로 이동하는 작업으로 여겨지기 때문입니다. 게다가 이는 디버깅 하기 까다로운 stale closure
문제를 예방하곤 합니다.
또한 이러한 규칙을 잘 준수할 수 있도록 도와주는 eslint plugin
이 있습니다. 여러분이 만약 queryFn
내부에서 파라미터를 사용하고 있다면 queryKey
에서 의존성으로서 명시하도록 도와주며 이는 자동으로 fixable
하기에 해당 플러그인을 사용되는 것이 매우 권장됩니다.
여러분들이 queryKey
를 useEffect
에서 사용되는 의존성 배열과 비슷하다고 생각할 수는 있으나 의존성 배열처럼 참조적 안정성(referential stability
)에 주의해야할 필요는 없습니다. 위의 예시처럼 참조적 안정성을 지키려고 queryKey
나 queryFn
에 메모이제이션 훅을 사용하는 행위까지는 할 필요 없다는 의미입니다.
이제 마지막으로 새로운 문제에 대해서 소개해보자 합니다. 우리는 useQuery
라는 훅을 원하는 컴포넌트 계층에서 자유롭게 쓰고 있는데 만약 특정 컴포넌트에서만 존재하는 값이 Query
의 의존성이 되어버린다면 어떻게 해야할까요? 예를 들어 useIssues
라는 훅이 filters
에 의존적인 상황에서 filters
에 대한 접근 권한이 특정 컴포넌트에게만 있는 제한적인 상황이라면 어떻게 해야할까요?
정답은 다시 말하지만, 리액트 쿼리가 이에 대해 관여하지 않는다 입니다. 이러한 문제는 순전히 클라이언트 상태 관리 문제일 것입니다. 왜냐하면 위 예시에서의 filter
값은 클라이언트 상태이기 때문이죠. 그리고 그것을 어떻게 관리할지는 여러분에게 달려있고요. 지역상태나 전역 상태나 여러분에게 가장 적합한 것을 사용하면 됩니다. filters
라는 값을 url
에 저장해두는 것 역시 좋은 방법이 될 것입니다.
예를 들어, zustand
와 같은 상태 관리자에게 filters
값을 저장했을때 어떠한 양상을 보이는지 확인해봅시다. 이전 예시와 다른 점은 그저 filters
가 커스텀 훅의 파라미터로 들어오는 대신에 스토어로부터 직접 얻어온다는 점 입니다. 이는 커스텀 훅을 작성할때 훅들을 합성을 하는 것의 강력함을 보여줍니다.
그리고 우리는 여기서 useQuery
를 통해 관리되는 서버 상태와 useStore
에 의해서 관리되는 클라이언트 상태의 극명한 차이를 느낄 수 있습니다. 우리가 스토어에 있는 filters
의 값이 변함에 따라 Query는 자동으로 실행되어 캐시로부터 이용 가능한 가장 최신의 데이터를 가져오게 되는 것 입니다.
이러한 패턴은 리액트 쿼리를 순수한 비동기 상태 관리자로서 사용하는 것을 가능하게 해줍니다.
이번에 다룬 내용들을 요약해보자면 다음과 같습니다.
리액트 쿼리는 데이터 패칭 라이브러리가 아니라 비동기 상태 관리 라이브러리 입니다.
staleTime
은 여러분의 가장 친한 친구입니다 - 그러나 필요에따라 알맞은 수정이 필요합니다.파라미터를 의존성으로 생각해야하며, 이전에 소개한 lint rule을 꼭 사용해야합니다.
앞서 언급한 이 세가지 원칙들을 따르도록 우리의 사고방식을 바꾸게 된다면 신발끈을 바로 매는 것이 우리의 삶의 질을 바꿔 놓은 것처럼 리액트 쿼리를 다루는데에 있어서 훨씬 더 나은 경험을 할 수 있게 될 것입니다.
오늘의 이야기는 여기까지 입니다. 트위터 팔로우와 블로그 구독을 부탁드립니다. 리액트 쿼리 v5 출시까지도 얼마 남지 않았으니 트위터와 블로그를 통해 이에 대한 최신 정보를 받아보실 수 있을 겁니다. 감사합니다!
글 잘 읽었습니다! 리액트쿼리를 사용해보진 않았지만, 관심 있는 기술이었고 캐시 기능이 매력적이라고만 생각했는데, 해당 기술에 대한 깊은 이해를 하는데 도움되었어요. 추후 해당 기술을 사용할 때 참고하며 도입해보겠습니다. 감사합니다!