🔥 React Query로 생각하기
이 글은 최근 비엔나에서 열린 모임과 React Summit의 원격 세션에서 제가 발표한 내용을 정리한 것입니다. 슬라이드를 좌우로 넘기거나 화살표 버튼 또는 키보드 화살표 키를 사용하여 이동할 수 있습니다. 즐겁게 읽어주세요!
안녕하세요 여러분 👋, 오늘 이 자리에 함께해 주셔서 감사합니다. 제가 오늘 여러분과 나누고 싶은 이야기는...
...신발 끈을 올바르게 묶는 방법입니다.
대부분의 사람들은 신발 끈을 묶는 데에도 올바른 방법과 그렇지 않은 방법이 있다는 사실을 모릅니다. 두 방법은 언뜻 보기에 비슷해 보이지만, 하나는 안정적인 매듭을 만들고 다른 하나는 걸을 때마다 풀립니다. 이 작은 차이가 여러분의 일상을 크게 바꿀 수 있습니다. 글 마지막에 이 팁을 소개해 드리겠습니다.
React Query를 사용할 때도 이와 비슷한 상황을 만날 수 있습니다. 작은 변화가 큰 차이를 만들어내는 경우가 있죠.
저는 2020년에 오픈 소스 여정을 시작했을 때 이러한 사실을 깨달았습니다. 주로 커뮤니티에 참여하며 도움을 주는 활동을 했죠.
여러 플랫폼에서 수많은 질문에 답변을 했는데, 이는 제가 오픈 소스 활동을 시작하는 데 큰 도움이 되었습니다. 사람들의 문제를 해결해 주면 그들이 얼마나 기뻐하고 고마워하는지 알게 되었고, 저 역시 직접 겪어보지 못한 상황들을 접하면서 많은 것을 배웠습니다.
이런 경험을 통해 React Query에 대해 깊이 이해하게 되었고, 그때 질문들 사이에서 공통된 패턴을 발견했습니다. 많은 질문들이 React Query가 무엇인지, 어떤 일을 하는지에 대한 근본적인 오해에서 비롯되었습니다. 이러한 질문들은 조금만 생각을 바꾸면 스스로 해결할 수 있는 것들이었죠.
제 이름은 도미닉이고, 비엔나에서 소프트웨어 엔지니어로 일하고 있습니다. 온라인에서는 거의 모든 곳에서 TkDodo라는 이름으로 활동하고 있습니다. Adverity에서 프론트엔드 기술 리더로 일하고 있으며, 지난 2년 동안 오픈 소스 라이브러리인 React Query의 유지 보수를 담당하는 특별한 기회를 가졌습니다.
오늘 제가 여러분과 나누고 싶은 것은 React Query를 더 나은 마음가짐으로 접근하는 세 가지 간단한 방법입니다. 신발 끈을 올바르게 묶는 것과 마찬가지로, 일단 알고 나면 매우 간단하고 이해하기 쉬운 방법이라는 것을 깨닫게 될 겁니다.
그럼 이제 "React Query에 대한 생각"을 어떻게 하면 좋을지 살펴보겠습니다.
첫 번째 포인트는 여러분을 놀라게 할 수도 있습니다. 하지만 사실입니다. React Query는 종종 "React에서 데이터 가져오기의 부족한 부분을 채워주는 도구"라고 설명되지만, 실제로는 데이터 가져오기 라이브러리가 아닙니다. React Query는 여러분을 위해 데이터를 가져오지 않습니다. 왜 그런지 간단한 예제를 통해 알아보겠습니다.
일반적인 React Query 예제를 보면 useQuery
에 두 가지를 제공해야 합니다:
하나는 React Query가 데이터를 저장할 고유한 queryKey
입니다.
다른 하나는 데이터를 가져와야 할 때마다 실행될 queryFn
입니다.
물론 이 훅을 컴포넌트에서 사용하여 데이터를 렌더링하고 쿼리가 가질 수 있는 다양한 상태를 처리할 수 있습니다. 하지만 queryFn
을 자세히 살펴보면...
이 예제에서는 axios를 사용하여 구현했습니다. 왜 axios를 사용했을까요? 중요한 점은 바로 이것입니다: axios가 여러분의 데이터 가져오기 라이브러리입니다. React Query는 여러분이 어떻게 데이터를 가져오는지에 대해 신경 쓰지 않습니다.
React Query가 관심을 가지는 유일한 것은 우리가 fulfilled
또는 rejected
Promise를 반환하는지 여부입니다.
사실 (이는 아마도 라이브러리 유지 보수자로서 하는 말일 수 있지만), 만약 여러분이 API가 비공개라서 재현할 수 없다고 말하며 이슈를 제기한다면, 저는 아마도 queryFn
을 구현하는 가장 간단한 방법이 이것이라고 말할 것입니다 - 데이터 가져오기 없이:
우리가 하는 일은 단순히 해결된 Promise를 반환하는 것뿐입니다. 물론 React Query는 axios, fetch, graphql-request와 같은 데이터 가져오기 라이브러리와 매우 잘 어울립니다. 왜냐하면 이들 모두 Promise를 생성하기 때문입니다.
React Query가 데이터를 가져오지 않는다는 점을 이해하면, 데이터 가져오기와 관련된 많은 질문들이 사라집니다. 예를 들어:
데이터 가져오기와 관련된 모든 질문들은 대개 같은 답을 가집니다:
- React Query로 baseURL을 어떻게 정의할 수 있나요?
- React Query로 응답 헤더에 어떻게 접근할 수 있나요?
- React Query로 GraphQL 요청을 어떻게 만들 수 있나요?
React Query는 신경 쓰지 않습니다! 그저 어떻게든 Promise를 반환해 주세요.
좋습니다. 이 점을 이해했다면 이제 다음 질문을 할 수 있겠죠:
React Query가 데이터 가져오기 라이브러리가 아니라면, 대체 무엇일까요? 이 질문에 대한 제 답변은 항상 이렇습니다:
React Query는 비동기 상태 관리자입니다. 여기서 중요한 것은 "비동기 상태"가 무엇을 의미하는지 이해하는 것입니다.
React Query의 창시자인 Tanner Linsley는 2020년 5월에 "글로벌 상태와 헤어질 때"라는 제목의 훌륭한 강연을 했습니다.
이 강연은 아직도 매우 유용합니다. 아직 보지 않으셨다면 꼭 한번 보시기 바랍니다.
이 강연의 핵심은 우리가 오랫동안 상태를 어디에 둘 것인가에 따라 나누어 왔다는 것입니다. 한 컴포넌트에서만 필요하다면 로컬 상태를 사용하는 것으로 시작했을 겁니다. 트리 구조에서 더 위쪽에서 상태가 필요하다면 어떻게 할까요?
그렇다면 상태를 위로 올리고 필요하다면 props로 다시 아래로 전달합니다. 더 높은 곳에서, 또는 더 넓은 범위에서 상태가 필요하다면 어떻게 할까요?
아마도 Redux나 Zustand 같은 "전역 상태 관리자"로 옮기게 될 겁니다. 이들은 React 바깥에 존재하며 애플리케이션 전체에 상태를 배포합니다.
우리는 이런 방식으로 모든 종류의 상태를 다뤄왔습니다. 앱에서 클릭하는 토글 버튼이든, 네트워크를 통해 가져와야 하는 이슈 목록이나 프로필 데이터든 상관없이 모두 똑같이 취급해왔죠.
이제 생각을 바꿔볼 때입니다. 상태를 어디서 사용하는지가 아니라 어떤 종류의 상태인지에 따라 나누는 것입니다.
우리가 완전히 소유하고 동기적으로 사용할 수 있는 상태(예를 들어, 다크 모드 토글 버튼을 클릭할 때)와 원격에 저장되어 있고 비동기적으로 사용 가능한 상태(예를 들어, 이슈 목록)는 완전히 다른 요구사항을 가집니다.
비동기 상태 또는 "서버 상태"의 경우, 우리는 단지 데이터를 가져온 시점의 스냅샷만을 볼 수 있습니다. 이 상태는 쉽게 오래될 수 있습니다. 우리만이 이 상태의 소유자가 아니기 때문입니다. 백엔드, 아마도 우리의 데이터베이스가 실제 소유자입니다. 우리는 단지 그 스냅샷을 표시하기 위해 빌려온 것 뿐입니다.
브라우저 탭을 30분 동안 열어두고 다시 돌아왔을 때 이런 상황을 경험해 보셨을 겁니다. 자동으로 최신의 정확한 데이터를 볼 수 있다면 좋지 않을까요? 이는 우리가 데이터를 최신 상태로 유지해야 한다는 의미입니다. 다른 사용자들도 그 사이에 변경을 할 수 있기 때문입니다. 그리고 상태가 동기적으로 사용할 수 없기 때문에, 로딩 상태나 에러 상태와 같은 메타 정보도 함께 관리해야 합니다.
따라서 데이터를 자동으로 최신 상태로 유지하고 비동기 생명주기를 관리하는 것은 전통적인 범용 상태 관리자에서는 얻을 수 없거나 필요하지 않은 기능입니다. 하지만 비동기 상태에 특화된 도구가 있다면, 이 모든 것을 구현할 수 있고 더 많은 기능도 제공할 수 있습니다. 우리는 단지 적절한 도구를 적절한 작업에 사용하면 됩니다.
두 번째로 이해해야 할 부분은 "상태 관리자"가 무엇인지, 그리고 왜 React Query가 그 중 하나인지입니다. 상태 관리자가 일반적으로 하는 일은 앱에서 상태를 효율적으로 사용할 수 있게 만드는 것입니다. 여기서 중요한 부분은 '효율적으로'입니다. 다시 말해서, 이렇게 표현할 수 있습니다:
우리는 업데이트를 원하지만, 너무 많은 업데이트는 원하지 않습니다.
만약 너무 많은 업데이트가 문제가 되지 않는다면, 우리는 모든 상태를 React Context에 넣을 것입니다. 하지만 이는 실제로 큰 문제가 되며, 많은 라이브러리들이 다양한 방식으로 이 문제를 해결하려 합니다. 어떤 것들은 다른 것들보다 더 마법 같은 방식을 사용하죠. Redux와 Zustand - 두 가지 인기 있는 상태 관리 솔루션 - 모두 선택자 기반 API를 제공합니다:
이들은 우리의 컴포넌트가 관심 있는 상태의 일부분에만 구독하도록 합니다. 저장소의 다른 부분이 업데이트되어도 이 컴포넌트들은 신경 쓰지 않습니다. 그리고 이 원칙은 우리가 앱의 어디에서나 이러한 훅을 호출하여 해당 상태에 접근할 수 있게 해줍니다. 라이브러리들이 이를 전역적으로 사용 가능하게 만들기 때문입니다.
그리고 React Query에서도 크게 다르지 않습니다. 다만 여기서는 구독하는 부분이나 조각이 QueryKey에 의해 정의됩니다.
이제 우리의 useIssues()
커스텀 훅을 어디서 호출하든, 쿼리 캐시의 issues
조각에 변화가 있으면 업데이트를 받게 됩니다. 그리고 이것만으로 부족하다면, 한 걸음 더 나아갈 수 있습니다. React Query에도 선택자가 있기 때문입니다:
이제 우리는 "세밀한" 구독에 대해 이야기하고 있습니다. 여기서 컴포넌트들은 저장된 데이터의 계산된 결과나 파생된 결과에만 관심을 가집니다. 만약 하나의 이슈 상태를 "열림"에서 "닫힘"으로 바꾼다면, useIssueCount
훅을 사용하는 컴포넌트는 개수가 변하지 않았기 때문에 다시 렌더링되지 않을 것입니다.
그리고 다른 상태 관리자들과 마찬가지로, 우리는 (그리고 아마도 그래야만 합니다) 데이터에 접근하기 위해 필요한 곳 어디에서나 useQuery
를 호출할 수 있습니다.
이는 React Query에서 데이터를 다른 곳에 동기화하기 위해 useEffect
를 사용하거나, (이미 deprecated된) onSuccess
콜백에서 로컬 상태에 데이터를 설정하는 등의 모든 해결책이 안티 패턴임을 의미합니다.
이 모든 방법들은 단일 진실 소스를 없애는 상태 동기화의 형태이며, React Query가 이미 상태 관리자이기 때문에 불필요합니다. 따라서 그 상태를 다른 곳에 넣을 필요가 없습니다.
네, 알겠습니다. 여러분은 이제 이렇게 하고 계실 겁니다. 원하는 곳, 필요한 곳 어디에서나 useQuery를 호출하고 있죠. 3개의 컴포넌트, 3번의 useIssues()
호출. 하지만 우리의 컴포넌트 중 일부가 조건부로 렌더링된다면, 예를 들어 다이얼로그를 열 때나 종속적인 쿼리가 있을 때, 같은 엔드포인트에 대한 많은 요청을 보게 될 수 있습니다.
여러분은 이렇게 생각하실 수 있습니다: "으... 방금 2초 전에 이 데이터를 가져왔는데, 왜 또 가져오는 거지?" 그래서 문서를 찾아보고...
백엔드에 너무 많은 요청을 보내지 않기 위해 모든 곳에서 모든 것을 끄기 시작합니다. 어쩌면 우리가 데이터를 Redux에 넣었어야 했나 하는 생각이 들 수도 있겠죠...
잠시만 기다려주세요. 이 광기 속에는 어떤 논리가 있습니다. React Query가 왜 이렇게 많은 요청을 하는 걸까요?
이는 우리를 비동기 상태의 요구사항으로 다시 돌아가게 합니다: 이 데이터는 오래될 수 있으므로, 우리는 어느 시점에 업데이트하기를 원합니다. React Query는 특정 트리거에 의해 이를 수행합니다: 윈도우 포커스, 컴포넌트 마운트, 네트워크 연결 복구, 그리고 QueryKey 변경.
이 중 하나의 이벤트가 발생할 때마다 React Query는 해당 쿼리를 자동으로 다시 가져옵니다.
하지만 이게 전부는 아닙니다. React Query는 모든 쿼리에 대해 이렇게 하지 않습니다 - 오직 stale
로 간주되는 쿼리에 대해서만 이렇게 합니다. 이는 오늘의 두 번째 중요한 포인트로 우리를 이끕니다:
staleTime
은 여러분의 가장 친한 친구입니다.
React Query는 데이터 동기화 도구이기도 하지만, 이는 모든 쿼리를 무작정 백그라운드에서 다시 가져온다는 의미는 아닙니다. 이 동작은 staleTime
에 의해 조정될 수 있습니다. staleTime
은 "데이터가 오래된 것으로 간주되는 시간"을 정의합니다. stale
의 반대는 fresh
입니다. 다시 말해, 데이터가 fresh
로 간주되는 한, 우리는 캐시에서만 데이터를 받게 되며 다시 가져오지 않습니다. 그렇지 않으면 캐시된 데이터와 함께 다시 가져오기가 수행됩니다.
따라서 오래된 쿼리만 자동으로 업데이트됩니다. 하지만 중요한 점은: staleTime의 기본값이 0이라는 것입니다.
그렇습니다. 0밀리초입니다. 따라서 React Query는 모든 것을 즉시 오래된 것으로 표시합니다. 이는 확실히 공격적이며 과도한 요청을 초래할 수 있습니다. 하지만 React Query는 네트워크 요청을 최소화하는 쪽이 아니라 데이터를 최신 상태로 유지하는 쪽에 무게를 둡니다.
staleTime
을 정의하는 것은 여러분의 몫입니다 - 이는 여러분의 리소스와 요구사항에 크게 좌우됩니다. staleTime
에 대한 "정확한" 값은 없습니다.
서버가 재시작될 때만 변경되는 설정을 쿼리한다면, staleTime: Infinity
가 좋은 선택일 수 있습니다.
반면에 여러 사용자가 동시에 업데이트하는 협업 도구를 가지고 있다면, staleTime: 0
에 만족할 수 있습니다.
따라서 React Query를 사용하는 데 있어 매우 중요한 부분은 staleTime
을 정의하는 것입니다. 다시 말하지만, 정확한 값은 없습니다. 제가 좋아하는 방법은 전역적으로 기본값을 설정하고 필요할 때 덮어쓰는 것입니다.
좋습니다. 빠르게 비동기 상태의 요구사항으로 한 번 더 돌아가 보겠습니다. 우리는 React Query가 데이터가 오래된 것으로 간주되고 이벤트 중 하나가 발생하면 캐시를 최신 상태로 유지한다는 것을 알고 있습니다.
아마도 가장 중요한 이벤트는 QueryKey 변경 이벤트일 것입니다. 이 이벤트는 주로 언제 발생할까요? 이는 우리를 마지막 포인트로 이끕니다:
우리는 매개변수를 종속성으로 취급해야 합니다.
이 점을 정말 강조하고 싶습니다. 이미 문서에 설명되어 있고 별도의 블로그 포스트로도 작성했지만 말이죠.
만약 이 예제의 필터와 같이 queryFn
내에서 요청을 만들기 위해 사용하고 싶은 매개변수가 있다면, 반드시 queryKey
에 추가해야 합니다.
이렇게 하면 React Query를 사용할 때 많은 이점을 얻을 수 있습니다. 우선, 입력에 따라 항목들이 별도로 캐시되도록 합니다. 따라서 다른 필터를 사용하면 캐시에서 다른 키로 저장되어 경쟁 상태를 피할 수 있습니다.
또한 filters
가 변경될 때 자동으로 다시 가져오기를 가능하게 합니다. 우리는 하나의 캐시 항목에서 다른 항목으로 이동하기 때문입니다. 그리고 디버깅하기 어려운 오래된 클로저 문제도 피할 수 있습니다.
이는 매우 중요해서 우리는 자체 eslint 플러그인을 출시했습니다. 이 플러그인은 queryFn
내부에서 무언가를 사용하고 있는지 확인하고 키에 추가하라고 알려줍니다. 자동으로 수정할 수 있으며, 사용을 강력히 추천합니다.
원한다면 queryKey
를 useEffect
의 의존성 배열처럼 생각할 수 있습니다. 하지만 단점은 없습니다. 참조 안정성에 대해 걱정할 필요가 없기 때문입니다.
queryFn
이나 queryKey
에 대해 useMemo
나 useCallback
을 사용할 필요가 없습니다.
이제 마지막으로, 이로 인해 새로운 문제가 발생할 수 있습니다. 우리는 이제 원하는 곳, 필요한 곳 어디에서나 useQuery
를 사용하고 있습니다. 앱의 어느 레벨에서든 3개의 컴포넌트, 3번의 useIssues()
호출을 하고 있죠. 하지만 이제 우리의 쿼리에 의존성이 있는데, 이 의존성은 화면의 특정 부분에만 존재합니다. useIssues
를 호출하고 싶은데 filters
에 접근할 수 없다면 어떻게 해야 할까요? 이 filters
는 어디서 오는 걸까요?
답은 다시 한 번, React Query는 신경 쓰지 않는다는 것입니다. 이는 순수한 클라이언트 상태 관리 문제입니다. 적용된 필터는 _클라이언트 상태_이기 때문입니다. 이를 어떻게 관리할지는 여러분에게 달려 있습니다.
여전히 필요에 따라 로컬 상태나 전역 상태 관리자를 사용하는 것은 완전히 괜찮습니다. filters
를 URL에 저장하는 것도 좋은 아이디어일 수 있습니다.
예를 들어, filters
를 zustand
와 같은 상태 관리자에 넣었을 때 어떻게 보일지 살펴보겠습니다:
우리가 변경한 유일한 점은 커스텀 훅에 filters
를 입력으로 전달하는 대신 직접 스토어에서 가져오는 것입니다. 이는 커스텀 훅을 작성할 때 컴포지션의 힘을 보여줍니다.
그리고 우리는 서버 상태(React Query로 관리)와 클라이언트 상태(이 경우 useStore
로 관리)의 명확한 분리를 볼 수 있습니다. 스토어에서 filters
를 업데이트할 때마다 - 어디에서든 - 쿼리는 자동으로 실행되거나 사용 가능한 경우 캐시에서 최신 데이터를 읽을 것입니다.
이 패턴을 사용하면 React Query를 진정한 비동기 상태 관리자로 사용할 수 있게 됩니다.
요약하자면:
- React Query는 데이터 가져오기 라이브러리가 아닙니다 - 비동기 상태 관리자입니다.
staleTime
은 여러분의 가장 친한 친구입니다 - 하지만 여러분의 필요에 맞게 설정해야 합니다.- 매개변수를 종속성으로 취급하고, 우리의 린트 규칙을 사용하여 이를 강제하세요.
이 세 가지 포인트에 따라 생각을 바꾸면, 신발 끈을 묶는 방법의 작은 변화가 삶의 질을 크게 향상시킬 수 있는 것처럼 React Query를 사용하는 데 있어 더 나은 경험을 할 수 있을 것입니다.
이제 약속드린 신발 끈을 올바르게 묶는 방법을 알려드리겠습니다.
정말 간단합니다. 고리를 만들 때, 먼저 신발 끈을 자신 쪽으로 당긴 다음 틈새로 통과시키세요.
이 작은 차이로 인해 매듭이 가로로 유지되고 쉽게 풀리지 않는 결과를 얻을 수 있습니다.
이에 대한 YouTube 동영상도 있으니 보시면 좋겠습니다.
이상으로 제가 준비한 내용을 모두 말씀드렸습니다. 경청해 주셔서 감사합니다. 트위터에서 저를 팔로우하고, 블로그를 구독해 주세요. React Query v5가 곧 출시될 예정이니 이를 통해 최신 정보를 확인하실 수 있습니다. 감사합니다!