[번역] Infinite Queries 동작 원리

이춘구·2024년 9월 18일
0

translation

목록 보기
13/13

Photo by Reuben

TkDodoHow Infinite Queries work를 번역한 글입니다.


이번 주에 React Query의 Infinite Queries에 대해 아주 흥미로운 버그 리포트가 제출됐습니다. 그 때까지 저는 React Query에 어떠한 버그도 없다고 굳게 믿었기 때문에 흥미로웠습니다.

네, 사실 버그가 있긴 하죠. 하지만 저는 1)다수의 사용자에게 영향을 미치며, 2)라이브러리 자체의 설계상 제약으로 인한 버그는 없다고 꽤나 확신하고 있었습니다.

물론 상당히 특수한 상황이라 우회책(이거 없이 못살죠)이 필요한 버그들도 있고, 서스펜스가 쿼리 취소와는 작동하지 않는 등 받아들이기 짜증나는, 그런 알려진 한계들도 있습니다.

훌륭한 버그 리포트 제출하기 🐞

좋은 버그 리포트는 어떻게 생겼나 궁금한 적이 있다면 이 이슈만 보면 됩니다.

최소한의 재현을 위한 샌드박스와 샌드박스에서 무엇을 어떻게 하면 되는지 명확하게 설명합니다. 실사례에 대해 언급하면서도 써드파티 의존성 패키지는 포함하지 않죠. queryFnfetch조차 하지 않습니다.

이건 아름다울 정도예요. 이슈는 Young Park처럼 제출해 주시기 바랍니다.

하지만 이 버그 리포트는 다르게 다가왔습니다. 분명 잘못된 동작이었죠. 저희가 되돌린 적이 있는 것도 아니라 항상 같은 방식으로 동작해 왔습니다. 그럼에도 예외 케이스로 분류할 수 있었는데, 이런 일이 생기려면 아래 조건들을 만족해야 하기 때문입니다.

  • Infinite Query가 이미 여러 페이지를 성공적으로 fetch한 적이 있어야 함.
  • refetch가 최소 한 페이지를 성공적으로 fetch했지만, 다음 페이지는 실패해야 함.
  • retry를 적어도 1회 사용해야 함. (기본값이 3회)

매일 마주칠 만한 건 아니지만 또 엄청 예외적인 케이스도 아닙니다. 저는 이걸 제보한 사람이 지난 4년간 한 명도 없다는 게 놀라웠습니다. 그래서 트위터에서 물어봤는데, 사용자들이 예전에 이 버그를 만난 적은 있지만 React Query에 이런 큰 결함이 있을 거라 생각하지 않았기에 제보하지 않은 것으로 보였습니다. 적어도 React Query의 전반적인 퀄리티에 대해선 모두 같은 생각같네요. 🙌


이 이슈(그리고 처음에 저를 놀라게 한 이유)를 이해하려면, 무한 쿼리가 일반적인 "단일 쿼리"와 어떻게 다른지 이해해야 합니다.

무한 쿼리

무한 쿼리는 우리 모두 싫어하는 망할-스크롤 페이지를 어느 정도 단순하게 구현하기 위한 React Query의 방식입니다. 여러 측면에서 단일 쿼리와 동일하죠.

캐시 안에서 모든 쿼리는 Query 클래스의 인스턴스로 표현됩니다 (#18: Inside React Query를 읽은 적 없다면 지금이 딱이에요). 인스턴스는 쿼리와 관련된 상태의 관리를 책임지고, 현재의 fetch에 대한 promise를 들고 있습니다. 이게 중복 제거를 가능하게 하죠. 예를 들어, query가 이미 fetching 상태인 도중에 query.fetch가 또 호출되면, 활성 상태인 프로미스를 재사용하는 겁니다.

추가로 쿼리는 retryer의 인스턴스도 들고 있습니다. 이 인스턴스는 재시도에 관련된 모든 로직 실행을 혼자 책임지죠. 쿼리가 데이터를 fetch하고 싶으면, retryer에게 시작하라고 알리고 promise를 반환 받습니다. 이 프로미스는 재시도 횟수가 모두 소진된 후에 resolve나 reject 됩니다.

간소화한 의사 코드는 아래와 같습니다.

// retryer

 1 class Query() {
 2   fetch() {
 3     if (this.state.fetchStatus === 'idle') {
 4       this.#dispatch({ type: 'fetch' })
 5       this.#retryer = createRetryer(
 6         fetchFn: this.options.queryFn,
 7         retry: this.options.retry,
 8         retryDelay: this.options.retryDelay
 9       )
10       return this.#retryer.start()
11     }
12
13     return this.#retryer.promise
14   }
15 }

retryer는 전달받은 fetchFn을 호출하며, 재시도를 할 때는 여러번 호출할 수도 있습니다 (버그를 이해하는 데 중요하니 기억해두세요). 이 모든 게 단일 쿼리나 무한 쿼리나 똑같습니다. 캐시 안에 InfiniteQuery를 대표하는 게 따로 없기 때문이죠.

단일 쿼리와 다른 점

무한 쿼리의 유일한 차별점은 data의 구조와 data를 반환받는 방식입니다. 일반적으로 queryFn이 반환하는 건 캐시로 직접 연결됩니다. 단순한 1:1 관계죠.

무한 쿼리에서 각각의 queryFn 호출들은 모두 전체 데이터 구조의 일부(한 페이지)만 반환합니다. 이 페이지들은 마치 연결 리스트처럼 이전 페이지에 의존해서 자신의 데이터를 가져옵니다.

Query.gg 🔮

무한 쿼리에 대한 저의 훨씬 더 깊은 설명은 ui.dev와 협업 중인 React Query 공식 강의에서 들을 수 있습니다. 이 강의를 통해 React Query의 내부 작동 방식과 확장성 있는 React Query 코드의 작성법에 대한 기본 원칙을 이해할 수 있습니다. 지금까지 제가 만든 콘텐츠가 마음에 들었다면 query.gg도 그럴 거예요.

하지만 개념적으로, 무한 쿼리는 아직 QueryKey 하나에 붙어 사는 쿼리 하나일 뿐입니다. 차별점은 여기에 다른 QueryBehavior를 붙이면서 생기죠.

QueryBehavior

위에서 queryFnretryer로 직접 전달된다고 한 건 약간의 거짓이 섞여있었습니다. 주위에 얇은 레이어가 하나 있거든요. 단일 쿼리는 queryFn만 실행하도록 설정되지만, 무한 쿼리는 infiniteQueryBehavior에서 꺼낸 함수를 실행합니다.

// query-behavior

   1 class Query() {
   2   fetch() {
   3     if (this.state.fetchStatus === 'idle') {
   4       this.#dispatch({ type: 'fetch' })
   5       this.#retryer = createRetryer(
>  6         fetchFn: this.options.behavior.onFetch(  
>  7           this.context,
>  8           this.options.queryFn
>  9         ),
  10         retry: this.options.retry,
  11         retryDelay: this.options.retryDelay
  12       )
  13       return this.#retryer.start()
  14     }
  15
  16     return this.#retryer.promise
  17   }
  18 }

무한 쿼리의 behavior는 자신이 실행됐을 때 뭘 해야 하는지 압니다. 예를 들어, 우리가 fetchNextPage를 호출하면 behavior는 전달받은 queryFn을 한 번 실행한 뒤, 캐시된 데이터에 페이지를 덧붙여야 한다는 걸 알죠. refetch가 발생하면 루프 안에서 queryFn을 호출하는데, 일관성을 보장하기 위해 항상 getNextPageParam을 호출합니다.

// InfiniteQueryBehavior

 1 function infiniteQueryBehavior() {
 2   return {
 3     onFetch: (context, queryFn) => {
 4       return async function fetchFn() {
 5         if (context.direction === 'forward') {
 6           return [...context.data, await fetchNextPage(queryFn)]
 7         }
 8         if (context.direction === 'backward') {
 9           return [await fetchPreviousPage(queryFn), ...context.data]
10         }
11
12         const remainingPages = context.data.length
13         let currentPage = 0
14         const result = { pages: [] }
15
16         do {
17           const param = getNextPageParam(result)
18           if (param == null) {
19             break
20           }
21           result.pages.push(await fetchNextPage(queryFn, param))
22           currentPage++
23         } while (currentPage < remainingPages)
24
25         return result
26       }
27     },
28   }
29 }

개념적으로 훌륭한 디자인이라고 생각합니다. 어떤 쿼리를 무한 쿼리로 만드려면 그 쿼리에 infiniteQueryBehavior만 붙이면 됩니다. 나머지는 변함없이 작동하죠. queryClientfetchInfiniteQuery가 말 그대로 딱 이렇게 합니다.

// fetchInfiniteQuery

1 fetchInfiniteQuery(options) {
2   return this.fetchQuery({
3     ...options,
4     behavior: infiniteQueryBehavior()
5   })
6 }

여기서 더 할 건 없습니다. 캐싱, 재검증, 구독도 아무 차이 없죠. 그럼 버그는 어디있는 걸까요?

버그 🐞

이 버그는 위계와 관계가 있습니다. queryretryer를 들고 있고, retryerinfiniteQueryBehavior가 반환하는 fetchFn 를 받죠. 위에서 말했듯 retryer는 오류나 재시도를 포착하면 fetchFn을 여러번 실행할 수도 있습니다.

fetchFn에 fetch하는 루프가 있기 때문에, 재시도를 한다면 그 루프 전체를 재시작하고 다시 fetch합니다. 첫 페이지를 fetch하는 데 실패하는 건 상관없지만 중간 페이지를 실패하면(재현된 버그는 실사례로 레이트 리미팅을 듦), 루프를 리셋하고 완전히 처음부터 시작하게 됩니다. 즉, 레이트 리미팅을 하면 모든 페이지를 fetch하는 게 아예 불가능할 수도 있다는 겁니다!

이건 절 기겁하게 했습니다. 왜냐면 제가 설계를 의심하는 중이었거든요. 순서를 뒤집어야 하나? infiniteQueryBehavior 안의 모든 fetch에 각자의 retryer가 필요한가? 이러면 거대한 리팩터링이 될 것이고 단일 쿼리에도 영향을 미치게 될 가능성이 높았습니다.

수정 🕵️‍♂️

저는 이 버그에 대한 생각을 멈출 수 없었습니다. 저 레이어들을 완전히 재작성하고 싶지 않았죠. 여기에서 누락된 단 한 가지는 infiniteQueryBehavior가 반복문을 재시작하는 지점을 기억하게 만드는 것이라고 생각했습니다. 이건 자바스크립트의 클로저를 사용하면 되는 사소한 것으로 드러났습니다. 관련된 정보를 fetchFn 함수의 외부로 끌어올려서, 이 함수가 다시 호출되었을 때 어디까지 실행했는지 '기억'하게 할 수 있습니다.

// hoisting

  1 function infiniteQueryBehavior() {
  2   return {
  3     onFetch: (context, queryFn) => {
> 4       const remainingPages = context.data.length
> 5       let currentPage = 0
> 6       const result = { pages: [] }
  7
  8       return async function fetchFn() {
  9         if (context.direction === 'forward') {
 10           return [...context.data, await fetchNextPage(queryFn)]
 11         }
 12         if (context.direction === 'backward') {
 13           return [await fetchPreviousPage(queryFn), ...context.data]
 14         }
 15  
 16         do {
 17           const param = getNextPageParam(result)
 18           if (param == null) {
 19             break
 20           }
 21           result.pages.push(await fetchNextPage(queryFn, param))
 22           currentPage++
 23         } while (currentPage < remainingPages)
 24
 25         return result
 26       }
 27     },
 28   }
 29 }

이렇게 하면, fetchNextpage가 실패했을 때 retryer는 정지하고 결국 fetchFn을 다시 호출합니다. 하지만 이제 fetchFn은 어디서부터 계속해야 하는지 알고, 이전에 성공적으로 fetch한 페이지들의 정보도 여전히 간직합니다. 🎉

물론 이건 retry: 3로 설정 시, 재시도 횟수가 페이지 당 3회가 아니라 전체 페이지에서 3회라는 걸 의미합니다. 하지만 여전히 단일 쿼리의 동작 방식과는 일치합니다. 실제 fetch를 얼마나 자주 하든 재시도는 쿼리 당 3회죠.

실제 수정 PR은 GitHub에서 찾아볼 수 있습니다. 그리고 저와 함께 이 문제를 처리하고 첫 실패 테스트 케이스를 만든 incepter에게 감사를 전합니다. 🙏

물론 PR에 회귀를 추가했고 tRPC v11을 망가뜨렸지만 그 얘긴 나중에 하죠...

profile
프런트엔드 개발자

0개의 댓글