tanstack query 오픈소스 기여 후기

thalassophilia·2026년 4월 16일
post-thumbnail

우리 팀에는 Vue로 구성된 프로젝트가 몇 개 있었고, 기존에는 전반적으로 Pinia를 중심으로 상태를 관리하고 있었다. 그런데 서버 상태와 클라이언트 상태를 같은 방식으로 다루는 데에는 분명한 한계가 있었고, 점점 vue-query를 도입해보자는 이야기가 나오기 시작했다.

vue-query를 도입하면서 가장 먼저 부딪힌 주제 중 하나는 "쿼리 키와 쿼리 함수를 어떻게 관리할 것인가"였다. 프로젝트 수가 늘어나고 화면마다 비슷한 데이터 요청 패턴이 반복되다 보니, 각자 useQuery를 호출하면서 queryKey, queryFn, staleTime, select 같은 옵션을 제각각 작성하는 방식은 금방 유지보수 비용으로 돌아올 수밖에 없었다.
그래서 우리 팀은 여러 상황을 가정해본 끝에 query factory 구조를 사용하자고 결정했다.

이 구조를 택한 이유는 단순히 코드를 예쁘게 정리하기 위해서만은 아니었다.
query factory를 사용하면 다음과 같은 장점이 있었다.

  • 쿼리 키를 한 곳에서 일관성 있게 관리할 수 있다.
  • queryFn과 공통 옵션을 함께 묶어 재사용할 수 있다.
  • 같은 데이터를 조회하는 로직을 화면마다 조금씩 다르게 작성하는 일을 줄일 수 있다.
  • 타입 추론을 최대한 유지한 채로 useQuery, prefetchQuery, invalidateQueries 등을 같은 기준으로 다룰 수 있다.

특히 우리 팀에서는 "쿼리 키를 표준화해서 관리한다"는 점보다도, 쿼리 옵션까지 하나의 단위로 묶어서 재사용할 수 있다는 점을 중요하게 봤다.
예를 들어 어떤 데이터를 조회할 때 queryKey와 queryFn만 필요한 게 아니라, staleTime, gcTime, placeholderData, select 같은 옵션이 함께 따라붙는 경우가 많다. 이럴 때 query factory를 쓰면 "이 쿼리는 이렇게 동작한다"는 규칙 자체를 하나의 팩토리로 캡슐화할 수 있었다.

그런데 이 컨벤션을 구체적으로 정리하는 과정에서 한 가지가 계속 거슬렸다.
useQuery나 useInfiniteQuery는 query factory가 반환하는 옵션 객체를 자연스럽게 받아서 사용할 수 있었는데, prefetch는 흐름이 달랐다. 기존 코드에서 usePrefetchQuery를 찾아보니 아래와 같이 queryClient에 직접 접근하는 방식으로 사용하고 있었다.

처음에는 이 정도 차이는 그냥 감수하면 되는 것 아닐까 싶기도 했다. 하지만 생각해볼수록 이 방식은 우리가 정한 방향과 잘 맞지 않았다.

queryClient.prefetchQuery()를 직접 호출하면 결국 호출하는 쪽에서 다시 queryKey, queryFn, 옵션을 조립해야 한다. 그렇게 되면 조회 시점에는 query factory를 쓰고, prefetch 시점에는 다시 수동으로 옵션을 만든다는 이상한 구조가 된다.
겉으로 보기에는 사소한 차이 같지만, 실제로는 다음과 같은 문제가 생긴다.

  • 같은 쿼리를 조회할 때와 prefetch할 때 옵션이 미묘하게 달라질 수 있다.
  • 팀이 정한 쿼리 팩토리 구조를 모든 진입점에서 일관되게 활용할 수 없다.
  • "이 데이터는 어떤 정책으로 캐싱되는가"라는 규칙이 분산된다.
  • 나중에 옵션이 바뀌었을 때 조회 코드와 prefetch 코드를 각각 찾아 수정해야 한다.

즉, 문제는 단순히 "컴포저블이 하나 없어서 불편하다"가 아니었다.
query factory를 도입해 얻고 싶었던 장점을 prefetch 경로에서는 제대로 누릴 수 없다는 점이 더 아쉬웠다. 팀이 컨벤션을 정한 이유가 중복 제거와 일관성 확보에 있었는데, prefetch만 예외처럼 빠져 있으면 결국 중요한 지점에서 다시 수동 관리로 돌아가게 되기 때문이다.

공식 문서를 다시 확인해보니, tanstack/vue-query에는 usePrefetchQueryusePrefetchInfiniteQuery가 제공되지 않고 있었다. 반면 React Query 쪽에는 이미 훅을 제공해주고 있었고,(사실상 vue 외에 모든 라이브러리/프레임워크에서 재공해주고 있었다) Vue에서는 queryClient를 직접 사용하는 방식을 안내해주고 있었다.

이게 정말 의도된 차이인지 궁금해서 기존 discussion을 찾아봤다. 그 과정에서 나와 비슷한 문제의식을 가진 질문이 이미 올라와 있는 것을 발견했다.

메인테이너의 답변은 꽤 긍정적이었다. 직접 프로젝트에서 컴포저블을 만들어 써도 되고, 원한다면 usePrefetchQuery를 추가하는 PR을 올려도 된다는 내용이었다.

다만 그 discussion은 2년 전에 작성된 것이었기 때문에, 지금도 같은 방향으로 받아들여질지 확인하고 싶었다. 그래서 다시 한 번 discussion을 남겼고,

tkDodo 님이 흔쾌히 PR을 올려도 된다고 답변해주셨다.

답변을 확인한 뒤에는 "우리 팀에서만 임시로 래퍼를 만들어 쓸까, 아니면 아예 upstream에 기여할까"를 잠깐 고민했다.
로컬 래퍼를 만드는 건 빠른 해결책이지만, 결국 라이브러리 바깥에 별도의 규칙과 구현을 하나 더 두는 셈이었다. 그렇게 되면 팀 내부에서는 문제를 해결할 수 있어도, 장기적으로는 유지보수 포인트가 또 하나 생긴다. 반대로 upstream에 반영된다면 우리 팀뿐 아니라 같은 불편함을 겪는 다른 Vue Query 사용자들도 같은 방식으로 해결할 수 있다.
결국 "팀의 불편함을 임시 우회가 아니라 구조적으로 없애보자"는 쪽으로 생각이 기울었고, 구현을 시작하게 되었다.

우선 tanstack/query 레포를 포크한 뒤 CONTRIBUTING.md를 읽으면서 어떤 방식으로 기여를 진행해야 하는지부터 확인했다. 이후에는 이미 구현되어 있던 React Query 쪽 코드를 기준점으로 삼아 구조를 파악했다.
다만 단순 복붙으로 끝낼 수 있는 작업은 아니었다. 목표는 React와 동일한 사용 경험을 제공하되, Vue Query가 가지고 있는 반응성 특성을 자연스럽게 반영하는 것이었기 때문이다.

구현 과정에서는 특히 다음 부분을 신경 썼다.

React Query의 usePrefetchQuery 동작 방식과 API 표면을 먼저 확인했다.
Vue Query 쪽 기존 composable들이 MaybeRef, getter, computed 등을 어떻게 옵션으로 처리하는지 살폈다.
queryKey, enabled, 기타 옵션들이 Vue의 반응형 값으로 들어왔을 때도 자연스럽게 동작하도록 맞췄다.
단순히 usePrefetchQuery만 추가하는 것이 아니라, 빠져 있던 usePrefetchInfiniteQuery까지 함께 구현해 API 일관성을 맞췄다.
구현만으로 끝내고 싶지는 않았다. 이런 성격의 기능은 "된다"보다 "기존 패턴과 충돌 없이 안정적으로 동작한다"가 훨씬 중요하다고 생각했기 때문이다. 그래서 런타임 테스트와 타입 테스트를 함께 작성했다.
특히 아래와 같은 경우를 검증하려고 했다.

- 쿼리 상태가 이미 캐시에 있을 때는 불필요하게 prefetch하지 않는지
- reactive option이 변경되었을 때 기대한 방식으로 반응하는지
- 타입 추론이 기존 query options 흐름을 해치지 않는지
- infinite query에서도 동일한 사용 경험을 제공하는지

여기에 더해 문서도 함께 보완했다. 기능이 추가되더라도 공식 문서에 드러나지 않으면 실제 사용자 입장에서는 존재하지 않는 기능과 크게 다르지 않다고 생각했기 때문이다.
그래서 API 레퍼런스 문서에 usePrefetchQueryusePrefetchInfiniteQuery 관련 내용을 추가하고, 내비게이션에서도 해당 항목을 찾을 수 있도록 반영했다.

PR을 올리기 전에는 CONTRIBUTING.md 가이드에 적힌 절차대로 테스트를 모두 수행했다. 로컬에서 필요한 검증을 마친 뒤 PR을 올렸고, 약 열흘 정도 지난 후 메인테이너가 리뷰 후 머지해주었다.

PR 링크

이제 vue-query에서 usePrefetchQueryusePrefetchInfiniteQuery@tanstack/vue-query 5.98.0 이상부터 사용할 수 있다.

이번 경험이 특히 의미 있었던 이유는, 단순히 "오픈소스에 기여했다"는 사실 때문만은 아니다.
처음 출발점은 꽤 작았다. 팀에서 Vue Query 도입 컨벤션을 논의하다가, query factory를 중심으로 구조를 맞추고 싶었고, 그 과정에서 prefetch만 유독 예외처럼 느껴졌다. 처음에는 그냥 팀 내부 유틸로 감싸서 해결할 수도 있었겠지만, 그렇게 하면 우리가 중요하게 생각했던 일관성과 재사용성의 기준이 라이브러리 바깥으로 밀려나게 된다. 나는 가능하면 팀이 정한 좋은 기준을 "각자 알아서 지키는 규칙"이 아니라, 도구 자체가 자연스럽게 받쳐주는 형태로 만들고 싶었다.

결국 이번 컨트리뷰팅은 기능 하나를 추가하는 작업이라기보다, 팀이 더 일관된 방식으로 서버 상태를 다루게 만들고 싶다는 문제의식에서 시작된 일이었다.
실제로 컨벤션을 정하는 과정에서 발견한 불편함을 그냥 참고 넘어가지 않고, 왜 불편한지 구조적으로 설명해보고, 라이브러리 레벨에서 풀 수 있는지 확인하고, 그 해결책을 직접 구현해 반영해봤다는 점이 개인적으로도 꽤 인상 깊었다.

무엇보다 좋았던 건, 이 작업이 개인적인 만족으로 끝나지 않았다는 점이다. 팀 입장에서는 query factory와 query options를 조회와 prefetch 양쪽에서 더 일관되게 활용할 수 있게 되었고, 나 역시 "우리 팀이 더 좋은 방식으로 개발할 수 있도록 필요한 기반을 직접 개선해볼 수 있다"는 감각을 얻었다.
앞으로도 팀에서 일하다가 반복적으로 마주치는 불편함이 있다면, 단순히 우회하는 데서 멈추지 않고 정말 개선할 가치가 있는 문제인지 한 번 더 생각해보고 싶다. 그리고 그게 가능하다면, 팀 내부뿐 아니라 바깥 생태계에도 도움이 되는 방식으로 풀어내고 싶다.

0개의 댓글