요즘IT
위시켓
콘텐츠프로덕트 밸리
요즘 작가들컬렉션물어봐
콘텐츠
프로덕트 밸리
요즘 작가들
컬렉션
물어봐
새로 나온
인기
개발
AI
IT서비스
기획
디자인
비즈니스
프로덕트
커리어
트렌드
스타트업
서비스 전체보기
위시켓요즘IT
고객 문의
02-6925-4867
10:00-18:00주말·공휴일 제외
yozm_help@wishket.com
요즘IT
요즘IT 소개작가 지원
기타 문의
콘텐츠 제안하기광고 상품 보기
요즘IT 슬랙봇크롬 확장 프로그램
이용약관
개인정보 처리방침
청소년보호정책
㈜위시켓
대표이사 : 박우범
서울특별시 강남구 테헤란로 211 3층 ㈜위시켓
사업자등록번호 : 209-81-57303
통신판매업신고 : 제2018-서울강남-02337 호
직업정보제공사업 신고번호 : J1200020180019
제호 : 요즘IT
발행인 : 박우범
편집인 : 노희선
청소년보호책임자 : 박우범
인터넷신문등록번호 : 서울,아54129
등록일 : 2022년 01월 23일
발행일 : 2021년 01월 10일
© 2013 Wishket Corp.
로그인
요즘IT 소개
콘텐츠 제안하기
광고 상품 보기
개발

TanStack Query 너머를 향해: 쿼리를 라우트까지 전파시키기

FEConf
10분
0시간 전
71
에디터가 직접 고른 실무 인사이트 매주 목요일에 만나요.
newsletter_profile0명 뉴스레터 구독 중

FEConf2025에서 발표한 <TanStack Query 너머를 향해! 쿼리를 라우트까지 전파시키기>를 정리한 글입니다. React Server Component(RSC)와 TanStack Query를 함께 사용할 때 발생하는 구조적 문제를 해결하기 위한 접근법을 다룹니다. 본문에 삽입된 이미지의 출처는 모두 이 콘텐츠와 같은 제목의 발표 자료로, 따로 출처를 표기하지 않았습니다.

 

TanStack Query 너머를 향해! 쿼리를 라우트까지 전파시키기

라프텔 임상원 개발자

 

이번 발표에서는 React Server Component(RSC)와 TanStack Query를 함께 사용할 때 발생하는 구조적인 문제를 어떻게 해결할 수 있을지 이야기해 보려 합니다. 먼저 RSC의 기본 개념을 간단히 짚어본 후, TanStack Query와 함께 사용할 때 어떤 어려움이 생기는지 살펴봅니다. 그리고 이러한 문제를 해결하기 위해 직접 연구하고 적용해 본 방법론과 그 과정에서 만들어진 Data Ready 프레임워크도 함께 소개하겠습니다.

 

 

RSC는 서버와 클라이언트의 역할을 다시 나눕니다

React Server Component(RSC)는 다음과 같은 특징을 가집니다.

 

첫째, 클라이언트까지 도달하지 않는 컴포넌트입니다.

둘째, 서버 환경에서 동작하기 때문에 서버 자원을 더 효율적으로 사용할 수 있습니다.

셋째, 내부에서 비동기 연산을 사용할 수 있습니다.

넷째, 자식으로 클라이언트 컴포넌트를 사용할 수 있습니다.

 

전통적인 SSR에서 Isomorphic으로, 그리고 다시 분리로

클라이언트에 도달하지 않는다는 의미를 이해하려면 역사를 살펴볼 필요가 있습니다.

 

전통적인 SSR은 Spring, Ruby on Rails, Flask 같은 서버가 미리 HTML을 렌더링하고, 클라이언트 단에서 jQuery 같은 라이브러리가 실제 클라이언트 동작을 담당하는 구조였습니다. 서버와 클라이언트가 명확히 분리된 형태였죠. 그런데 React가 Isomorphic Component라는 개념을 제안하면서 서버와 클라이언트 모두 같은 컴포넌트를 사용하자는 주장을 했고, 꽤 오랜 시간 동안 React 생태계에서는 Isomorphic Component를 사용해왔습니다.

 

하지만 React Server Component는 다시 레트로로 회귀해서 "서버가 할 일은 서버가 알아야 한다"고 주장합니다. 왜 회귀했을까요? 서버만 알고 있는 정보, 특히 DB 계정이나 비밀 키, 파일 시스템과 같은 보안에 민감한 데이터를 서버 단에서 먼저 접근해 적절히 필터링하고 클라이언트에 내려주면, 효율적으로 서버가 HTML을 그리고 클라이언트에서 먼저 받아볼 수 있기 때문입니다.

 

물론 의문이 들 수 있습니다. "이거 Next.js 쓰면 다 되던 거 아닌가?" 특히 `getServerSideProps` 같은 Next.js 전용 API를 사용하면 아주 쉽게 되던 것들이 많습니다. 하지만 React Server Component의 의미는 특정 메타 프레임워크에 한정된 개념이 아니라 React 그 자체에 내장된 개념이기 때문에, 어느 프레임워크를 사용하든 React Server Component를 사용할 수 있다는 데 있습니다.

 

Dan Abramov가 쓴 "The Two Reacts"에서는 UI가 데이터와 State를 받는 함수라고 설명합니다. 그리고 RSC는 이를 커링해서 데이터를 먼저 받고 그다음에 State를 클라이언트에서 받는 구조로 변경됩니다. 그렇기 때문에 프레임워크와 상관없이 SSG, SSR, ISR 등 기존에 서버 사이드의 개입이 필요했던 렌더링이 React 어디서든 가능합니다.

 

 

비동기 연산은 Hook의 제약에서 자유롭습니다

앞서 언급한 비동기 연산이란, 기존에 Hook을 사용하던 코드 대신 그냥 비동기 함수를 호출해 `await` 함으로써 데이터를 가져올 수 있게 되었다는 의미입니다. 이런 특성 덕분에 기존의 Rules of Hooks처럼 "Hook을 조건부로 호출하면 안 된다" 같은 제약 사항이 사라집니다. 이건 그냥 `await` 하는 async 함수 호출이기 때문이죠.

 

나아가 Promise는 JavaScript 기본 기능이기 때문에 `Promise.all`이나 `try-catch`를 사용해 에러를 핸들링하는 등, 기존 Promise를 사용해 편하게 사용하던 기능들을 그대로 사용할 수 있다는 장점도 있습니다.

 

 

TanStack Query에 RSC를 접목하면 prefetch 지옥이 시작됩니다

TanStack Query를 사용해서 React Server Component를 활용하는 방법을 살펴보겠습니다.

 

TanStack Query의 공식 문서 중 Advanced Server Rendering 섹션을 참조하면, 먼저 `getQueryClient`라는 함수를 서버와 클라이언트 각 환경에 맞게 구현합니다. 서버에서는 매 요청마다 새로 만들고, 클라이언트에서는 전역 변수에 하나 할당해 계속 재사용하는 가장 간단한 구현을 보여주죠.

 

다음으로 루트 컴포넌트에서 `QueryClientProvider`를 통해 그러한 Query Client를 주입하는 기초적인 방식을 보여줍니다. 여기서 끝일까요? 아쉽게도 그렇지 않습니다.

 

 

prefetch 없이는 빈 껍데기만 내려갑니다

실제로 쓰는 쿼리들을 prefetch 하지 않으면 서버 단에서는 아무런 데이터가 불러와지지 않은 빈 Query Client가 내려갑니다. 그렇기 때문에 실제로 쓰는 쿼리들을 분석해 prefetch 함수를 호출해야 합니다.

 

Query Client 선언 아래에 `queryClient.prefetchQuery`라는 함수가 호출되는 것을 볼 수 있습니다. 이렇게 쿼리를 prefetch하고 나서 Query Client의 정보를 hydration해서 Hydration Boundary를 통해 내려줘야만 실제로 클라이언트에서 그 정보를 읽을 수 있습니다.

 

그렇다면 `useQuery`를 사용해서 편리하게 값을 읽을 수 있겠죠. 물론 클라이언트에서 값을 fetch하기 위해 기다리는 시간은 없을 겁니다.

 

 

depth가 깊어지면 prefetch를 관리할 수 없습니다

그런데 이게 가까이에서 provide 하면 상관없지만, depth가 엄청나게 깊어진다면 어떻게 될까요?

 

실제로 Post라는 컴포넌트에서 쿼리를 지웠을 때, 위에서 PostPage가 prefetch 쿼리를 지우는 것은 가까이 있었을 때는 쉽게 눈치챌 수 있었지만, 멀어지면 멀어질수록 눈치채기 어려워집니다. 그래서 불필요한 prefetch 쿼리가 남아 있을 수도 있죠.

 

 

다행히도 Server Component는 기존의 `getServerSideProps` 같은 라우트 레벨에서만 동작하는 함수와 다르게 어느 위치에서든 동작할 수 있기 때문에, Server Component를 실제 사용하는 Client Component와 가까이 둠으로써 이 문제를 해결할 수 있습니다.

 

 

하지만 Server Component가 중첩되면 중첩될수록 상위 컴포넌트에서 `await`이 끝나야만 하위 컴포넌트가 반환되고, 그게 계속 중첩되다 보면 직렬로 기다리면서 0.3초, 0.5초, 0.2초 기다린 동작이 다 합쳐져 1초가 걸리게 되는 딜레이 문제가 발생할 수도 있습니다.

 

정리하자면, 기존 `useQuery`를 유지하면서 점진적으로 prefetch 쿼리를 더해나가는 개선은 가능하지만, prefetch가 추가됨으로써 그것을 관리해야 한다는 복잡성이 추가되고, 또 제대로 설계하지 않을 경우 직렬로 기다리는 시간이 과도하게 증가할 수 있다는 문제가 있습니다.

 

 

 

그렇다고 TanStack Query를 버릴 수는 없습니다

반대로 TanStack Query를 아예 사용하지 않으면 어떨까요? 공식 문서에서 "Avoid Bringing React Query Until You Actually Need It"이라는 식으로 아예 필요가 없다면 굳이 쓰지 말라는 내용도 있습니다. 하지만 복잡한 상태를 관리할 때는 RSC만으로 부족할 수 있습니다. 무한 스크롤 예시를 통해 살펴보겠습니다.

 

무한 스크롤은 RSC만으로 구현하기 어렵습니다

무한 스크롤을 구현한다면 서버에서 할 수 있는 일은 아마도 앞쪽 몇 엘리먼트만 자른 다음, 그것을 초기 데이터로 클라이언트에 내려주는 것일 겁니다.

 

 

그렇다면 스크롤을 끝까지 했을 때 서버는 무엇을 해야 할까요? 크게 두 가지 전략을 생각해 볼 수 있습니다. 첫 번째 전략은 0부터 현재의 위치까지, 그리고 나서 더 봐야 할 내용까지 전부 그려서 내려주는 겁니다. 하지만 이렇게 되면 스크롤을 하면 할수록 점점 성능 저하가 심해집니다. 서버 단에서 계산을 너무 많이 해야 하니까요.

 

아니면 현재 오프셋과 그 뒤 내용만 잘라서 내려주는 방법이 있습니다. 하지만 이건 RSC에서 사용하는 방식을 잘 이해하지 않는다면 클라이언트 로직을 많이 짜야 하고, 그를 통해 클라이언트 로직 부담이 너무 심해질 수도 있습니다.

 

결국 클라이언트 상태 관리가 필요하다면 RSC를 써야 할 이유가 강하게 있을까요?

 

 

 

RSC를 효과적으로 쓰기 위한 방법

새로운 라이브러리를 만들고 설계하면서 RSC First 사상을 유지하고 병렬성도 챙기는 방향을 생각해 보았습니다. 새로 무언가를 만들기 전에 선행 연구를 찾아보는 것은 새로운 아이디어를 발견할 수도 있고, 또 선행 연구를 통해서 불필요한 작업을 줄일 수도 있기 때문에 중요합니다. 바퀴를 발명하기 전에 남의 바퀴를 찾아보는 것이 기본이죠.

 

토스는 requiredResources를 위로 전파했습니다

먼저 Toss Slash 22에서 김도환 님이 이런 코드 구조를 제안한 적이 있습니다. 각 컴포넌트는 리소스에 의존하는 방식이고, 그 리소스는 `Component.requiredResources`라는 변수에 넣는 방식입니다. 그리고 그 `requiredResources` 변수는 하위 컴포넌트에서 상위 컴포넌트로 단계별로 전파되고, 그것을 상위 컴포넌트에서 한 번에 prefetch한 다음 하위로 내려주는 구조를 취합니다.

 

 

Isograph는 쿼리를 컴파일해서 타입을 만들었습니다

다음으로는 Isograph라는 GraphQL 클라이언트 라이브러리입니다. 이 라이브러리의 경우 iso 함수 안에 GraphQL 쿼리를 넣고, 그 쿼리의 결과를 `data`라는 변수로 받는 구조를 취합니다.

 

Isograph가 특히 기발한 점은 저 쿼리를 컴파일해서 강타입 prop으로 만들어준다는 점입니다.

 

 

첫 번째 시도: 같은 파일에 두기

Isograph의 강타입 prop을 사용하면서 동시에 Toss 구조처럼 아래에서 위로 올려주는 리소스를 만들어보자는 생각을 했습니다. 먼저 Isograph의 API와 비슷한 형태로 필드를 선언하면 그 필드가 `data`라는 prop으로 들어올 수 있도록 간단한 쿼리 함수를 작성했고, 그 쿼리 함수가 실제로 실행되고 나서는 `requiredResources`라는 필드를 추가하도록 작성했습니다.

 

 

하지만 이 구조에는 가장 큰 문제가 있었습니다. `'use client'`가 사용된 경우 번들러가 Client Component 이외의 모든 선언을 내보내지 않습니다. 그렇기 때문에 변수를 참조하더라도 그 값이 `undefined`로 나오게 되죠.

 

두 번째 시도: 파일을 나눠서 옆에 두기

그래서 새로운 구조를 찾아볼 수밖에 없었습니다. 같이 두는 대신 파일을 나눠서 옆에 두는 방식으로 구조를 변경했습니다. 폴더 구조는 다음과 같이 작성했습니다. `loader.ts`에서 리소스를 정의하고, `client.tsx`에서 그 리소스를 바탕으로 타입을 추론해서 실제 prop 타입을 잡고, `index.ts`에서는 loader와 client를 같이 내보냄으로써 사용할 때 근처에 두는 방식입니다.

 

타입 안전성 같은 경우에는 TypeScript magic이 많기 때문에 생략했지만, `typeof`를 통해 `requiredResources`를 import해와서 `InferResourceProps`라는 타입을 사용하면 자동으로 데이터 타입을 추론하는 구조를 작성했습니다.

 

 

장점은 챙겼지만 문제는 남았습니다

컴포넌트와 쿼리가 가까이에 있다는 장점을 챙기고, 타입 안전하게 쿼리 결괏값을 꺼내 쓸 수 있으며, `requiredResources`를 위로 올려보내는 구조를 채택함으로써 페이지 단위에서 한 번에 prefetch를 할 수 있다는 장점을 모두 취했습니다.

 

하지만 아직 `requiredResources`가 무엇이고 그것을 어떻게 가져와야 하는지는 정의하지 않았고, 선언하는 것도 사용하는 것도 별개로 분리되어 있으니 조금 귀찮고, 이 `requiredResources`가 실제로 어떻게 HTTP 요청이나 파일 시스템을 읽는지는 아직 정의하지 않았으며, 무한 스크롤 문제도 풀지 않았습니다.

 

 

Data Ready는 이 모든 것을 표준화합니다

그래서 그런 문제들을 프레임워크 레벨에서 푸는 것을 목표로 하고 Data Ready라는 이름의 프레임워크를 개발하기 시작했습니다.

 

리소스를 표준화하고 보일러플레이트를 줄입니다

프레임워크의 목표는 첫 번째로 표준화였습니다. 앞서 말했듯이 리소스를 정의하지 않았기 때문에 리소스에 대해서 무엇을 할 수 있는지 확신할 수 없었습니다. 그래서 표준을 세워서 리소스가 무엇이고 어떤 것을 할 수 있고 어떻게 처리되는지 정의하고, 그에 따라서 각종 편의성을 제공하는 것을 첫 번째 목표로 삼았습니다. 이러한 편의성을 제공하는 것을 바탕으로, 보일러플레이트를 감소시켜 편리하게 사용하는 것을 목표로 삼았습니다.

 

쿼리는 캐싱 가능하고 비동기이며 읽기 연산입니다

이 리소스는 결국 값을 가져오는 것이니까 TanStack Query에서 쿼리의 정의를 가져왔습니다. 그러고 나서 나름대로 재해석해서 정리한 것이 이 문장입니다. 쿼리란 첫 번째로 캐싱 가능하고, 두 번째로는 비동기이면서, 세 번째로는 읽기 연산이고, 마지막으로 같은 쿼리 연산은 같은 키를 가져야 한다는 조건을 갖도록 쿼리를 정의했습니다.

 

 

useQuery가 반환하는 값은 너무 많습니다

그러고 나서 TanStack Query의 `useQuery`가 무엇을 반환하나 살펴봤는데, 너무 반환하는 값이 많습니다. 이 모든 것을 구현하다가는 늙어 죽을지도 모르죠. 그래서 먼저 Error Boundary라는 기능을 통해서 에러를 처리할 수 있기 때문에, 에러 관련된 값들은 Error Boundary를 통해서 처리하는 것을 구조로 잡았고, 그렇기 때문에 관련 값들을 다 지울 수 있었습니다.

 

다음으로는 우리가 만드는 것이 값을 서버 단에서 미리 읽고 그것을 내려주는 프레임워크니까, prefetch가 이미 되어 있거나, 아니면 Suspense를 통해서 클라이언트가 Suspense 안에 fallback을 보여주고 있는 중이라, 해당 값을 실제로 읽을 필요가 없는 경우, 이렇게 두 가지 경우만 있다고 생각해서 관련된 값들을 전부 버렸습니다. 로딩 상태는 더 이상 필요하지 않습니다.

 

그리고 다음으로는 refetch 관련해서 refetch를 mutation에 의존하거나, 그 외에 유틸리티 함수에 의존해서 사용하도록 하고, 쿼리가 반환하는 값에서는 refetch 관련된 정보들을 다 지워버렸습니다.

 

그렇게 다 지우고 나니까 데이터 관련된 값만 남았습니다. 이렇게 반환 타입까지 정리하니 우리가 쿼리를 어떻게 정의하고, 그것이 무엇을 돌려주는지에 대해서 확실하게 알 수 있었습니다.

 

 

dataReady.using 하나면 component와 fetchQueries를 얻습니다

그렇다면 이 쿼리를 어떻게 사용하는지 살펴보겠습니다. 예전에는 `requiredResources`를 그냥 아무 제약 없이 object로 정의했었는데, 그것을 `dataReady.using`이라는 함수 호출 하나를 추가함으로써 `component`와 `fetchQueries`라는 유틸리티 함수를 얻게 되었습니다.

 

그렇다면 이 `component` 함수를 직접 사용해 보겠습니다. 기존의 함수는 그냥 React Component에 `InferResourceProps`라는 유틸리티 타입을 사용해서 정의했었죠. 그것을 `component`라는 유틸리티 Higher Order Component를 사용해 데이터의 타입을 따로 유틸리티 타입에 의존하지 않고 그냥 loader에만 의존해서 자동으로 잡아주게 구조를 잡았습니다.

 

그리고 `fetchQueries`라는 유틸리티 함수를 제공하기 때문에, 앞서 몰랐던 "어떻게 실제로 리소스들의 값을 가져오고 그것을 내려주는지"에 대한 것이 여기서 `loader.fetchQueries`를 통해 실현되었다고 볼 수 있습니다.

 

 

무한 스크롤은 클라이언트에서 편리하게

이제 마지막으로 무한 스크롤 문제가 남았습니다. 사실 무한 스크롤을 굳이 서버 사이드에서 풀어야 하나라고 생각했습니다.

 

그냥 클라이언트 사이드의 로직을 짜고 충분히 편리한 Hook을 제공한다면 큰 문제가 없지 않을까 싶었습니다. 그래서 `data` 옆에 `querySet`이라는 쿼리 그 자체에 대한 정보를 같이 전달하도록 해서, 그 `querySet`에 의존해 `useInfiniteQuery`라는 함수를 호출해 가지고 `hasNext`나 `fetchMore` 같은 React Query에서 기존에 사용하던 친숙한 인터페이스로 무한 스크롤을 구현할 수 있게 했습니다.

 

그렇게 앞서 질문했던 네 가지가 전부 해결되었습니다.

 

 

마치며

Data Ready는 React Server Component와 TanStack Query를 함께 사용할 때 발생하는 구조적 문제들을 해결하기 위해 만들어진 프레임워크입니다. 컴포넌트와 쿼리를 가까이 두면서도 실행은 상위에서 병렬로 처리할 수 있게 했고, 타입 안전성을 확보하면서도 보일러플레이트를 최소화했습니다. 또한 무한 스크롤 같은 복잡한 클라이언트 상태 관리도 친숙한 인터페이스로 처리할 수 있도록 설계했습니다.

 

아직 이 라이브러리는 개념 증명 상태이며, 실제 프로덕션 환경에서 사용하기 위해 필요한 기능들이 더 많이 있습니다. 내부적으로 사용하면서 계속 연구해 나갈 예정이고, 완성도를 높여 오픈 소스로 전환할 계획입니다.

 

©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.