Apollo Client Pagination Fetching Guide
16 min readNov 28, 2022
목적
- 아폴로 클라이언트를 사용하여 페이지네이션 페칭을 가이드합니다
- 기존 사용 방식의 리팩토링 (
refetch
로 구현된 pagination 을fetchMore
로 전환)
결과물
- Page
{
...
// hook 호출
const { loading, data, totalCount, hasNextPage, elementRef } = useBundledProductContainerHook(productId, {
orderBy,
filter: {
categoryIds,
brandIds
},
})
...
}
- Hook
const useBundledProducts = (productId: string, { filter, orderBy }: ListVariablesProps) => {
const { pageNumber, setPageNumber, getTablePageInfoVariables } = usePageInfo(DefaultPageSize)
const {
data,
loading,
fetchMore: fetchMoreBundledProducts, // 데이터 추가 페칭
} = useBundledProductsQuery({
variables: {
filter: {
productId,
...filter,
},
pageInfo: {
first: DefaultPageSize,
},
orderBy,
},
fetchPolicy: 'cache-and-network', // !important, cache가 포함안되면 추가 페칭이 안됩니다
})
const { elementRef } = useIntersectionObserver(() => {
setPageNumber((prev) => prev + 1)
})
useEffect(() => {
if (pageNumber === 1) {
return
}
fetchMoreBundledProducts({
variables: {
pageInfo: {
first: DefaultPageSize,
after: getTablePageInfoVariables(pageNumber + 1, DefaultPageSize).pageInfo.after,
},
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) {
return previousResult
}
return {
...fetchMoreResult,
edges: [...(previousResult?.edges || []), ...(fetchMoreResult?.edges || [])],
}
},
})
}, [pageNumber])
return {
data: data?.edges.map((edge) => edge?.node) || [],
loading,
totalCount: data?.totalCount || 0,
hasNextPage: data?.pageInfo.hasNextPage || false,
elementRef,
}
}
export default useBundledProducts
리팩토링 과정
Bundled Products Hook 생성하기
- 기존의 상품 리스트 무한 스크롤은
pageNumber
를 증가시켜준 뒤,refetch
를 이용하여 변경된variable
을 보내주며 요청하고 있었습니다. - 먼저 기존 방식을 가져와 묶음 배송 상품의 무한스크롤 기능을 위한 새로운 hook 을 생성했습니다.
const useBundledProducts = (productId: string | string[], variables: ListVariablesProps) => {
// 기존에 만들어진 paging 관련 hook
const { getTablePageInfoVariables, setPageNumber, ...pageProps } = usePageInfo(DefaultPageSize)
// 상품 리스트를 담을 state
const [bundledProducts, setBundledProducts] = useState<ModelProduct[]>([])
const [loading, setLoading] = useState(false)
// 페이지 기본 variable 을 선언하고 parameter 로 받아온 variable 을 implement 시킨다
const queryVariables: ListVariablesProps = {
filter: {
productId,
...variables.filter,
},
pageInfo: {
first: DefaultPageSize,
...variables.pageInfo,
},
orderBy: variables.orderBy,
}
// elementRef 는 리스트의 마지막 요소의 ref에 넣어주면 됨
const { elementRef } = useIntersectionObserver(() => {
setLoading(true)
setPageNumber((prev) => prev + 1)
})
// pageNumber 가 변경되면 기존 variables 에 pageNumber로 계산한 cursor 를 넣어줌
const onScrollEnd = () => {
if (bundledProductsQuery.data??.pageInfo.hasNextPage) {
bundledProductsQuery.refetch({
...queryVariables,
...getTablePageInfoVariables(pageProps.pageNumber, pageProps.pageSize),
})
}
}
// api 호출이 완료되면 해당 함수를 이용하여 bundledList 를 세팅
const onCompletedFetch = (response: ResponseType) => {
if (!response) {
return
}
const result: ResultDataType = getResultFromData(response)
const dataKey = DataKey.Product
const { data }: { data: ModelProduct[] } = result || {
[dataKey]: { data: [] },
}
setBundledProducts([...bundledProducts, ...data])
setLoading(false)
return result
}
const bundledProductsQuery = useBundledProductsQuery({
variables: queryVariables,
fetchPolicy: 'no-cache',
onCompleted: onCompletedFetch,
onError() {
setLoading(false)
},
})
useEffect(() => {
// sorting 이나 productId 가 변경되면 리스트를 리셋하고 첫 페이지부터 다시 불러옴
if (productId) {
setBundledProducts([])
setPageNumber(1)
setLoading(true)
bundledProductsQuery.refetch(queryVariables)
}
}, [variables.orderBy, productId])
useEffect(() => {
// pageNumber 가 변경되면 scroll 이 감지된것으로 판단하여 `onScrollEnd` 함수를 호출
onScrollEnd()
}, [pageProps.pageNumber])
return {
bundledProducts,
loading,
totalCount: bundledProductsQuery.data?.totalCount || 0,
hasNextPage: bundledProductsQuery.data?.pageInfo.hasNextPage || false,
elementRef,
}
}
export default useBundledProducts
옆에서 보던 Howard 의 제안
- Howard says ‘
useQuery
가 제공하는fetchMore
가 있는데refetch
를 이용한다고?? 🤔’ - Howard 의 제안에 따라
apollo client fetchmore
로 검색해보니 공식 문서와 예제 사이트가 나왔습니다.
공식 문서와 개인 블로그 참고하여 개선
- 공식문서에서 알려주는
fetchMore
사용법입니다. (생각보다 간단해보이네!)
- 적용시켜봤습니다.
const useBundledProducts = (productId: string | string[], variables: ListVariablesProps) => {
...
// fetchMore 함수 작성
const fetchMoreQuery = () => {
bundledProductsQuery.fetchMore({
variables: {
// cursor 생성
...getTablePageInfoVariables(pageProps.pageNumber, pageProps.pageSize),
},
})
}
...
useEffect(() => {
// pageNumber 변경될 때 onScrollEnd 대신 fetchMore 호출하도록 수정
// onScrollEnd()
fetchMoreQuery()
}, [pageProps.pageNumber])
...
}
- 역시 기대한 대로 동작하지 않았..
- fetchMore tutorial 이라는 개인 블로그 의 예제에 따르면
fetchMore
에는 파라미터로updateQuery
가 필요했습니다. (공식문서 반성해~)
updateQuery
부분을 보니 이전 데이터와 새로 받은 데이터를 Response 로 받을 수 있고, 그 둘을 머지하여 리스트로 만드는 것 같았습니다.updateQuery
를 적용하여 수정해봤습니다.
const useBundledProducts = (productId: string | string[], variables: ListVariablesProps) => {
...
// fetchMore 함수 작성
const fetchMoreQuery = () => {
bundledProductsQuery.fetchMore({
variables: {
// cursor 생성
...getTablePageInfoVariables(pageProps.pageNumber, pageProps.pageSize),
},
updateQuery: (previousResult, { fetchMoreResult }) => {
onCompletedFetch(fetchMoreResult)
return [...previousResult, ...fetchMoreResult]
},
})
}
...
}
- 위와 같이 하니 리스트가 보여지기 시작했는데, 테스트 도중 페이징에 문제가 발견되었습니다. 같은 cursor 의 리스트를 계속 불러와 pageNumber 는 증가하지만 query 호출 시에는 기존의 query 를 호출하기 때문에 list 가 업데이트되지 않는 문제였습니다.
- 이리 저리 수정해봤으나 방도가 보이지 않아 Howard 에게 도움을 요청드렸고, Howard가 제시해주신 내용을 바탕으로 완성된 결과물!
const useBundledProducts = (productId: string | string[], { filter, orderBy }: ListVariablesProps) => {
const { pageNumber, setPageNumber, getTablePageInfoVariables } = usePageInfo(DefaultPageSize)
const {
data,
loading,
fetchMore: fetchMoreBundledProducts,
} = useBundledProductsQuery({
variables: {
filter: {
productId,
...filter,
},
pageInfo: {
first: DefaultPageSize,
},
orderBy,
},
fetchPolicy: 'cache-and-network',
})
// fetchPolicy 에는 캐시가 필요없는 경우 network-only 를 사용하는 것이 일반적이나
// fetchMore 의 경우 cache 사용이 필수로 필요합니다
// - fetchMore 의 경우 내부 cache 를 updateQuery 메소드에서 계속 활용하고 append 하여
// 리턴하기 때문에 'cache-and-network' 사용이 필요합니다
const { elementRef } = useIntersectionObserver(() => {
setPageNumber((prev) => prev + 1)
})
useEffect(() => {
fetchMoreBundledProducts({
variables: {
pageInfo: {
first: DefaultPageSize,
after: getTablePageInfoVariables(pageNumber + 1, DefaultPageSize).pageInfo.after,
},
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) {
return previousResult
}
return {
...fetchMoreResult,
edges: [...(previousResult?.edges || []), ...(fetchMoreResult?.edges || [])],
}
},
})
}, [pageNumber])
return {
data: data?.edges.map((edge) => edge?.node) || [],
loading,
totalCount: data?.totalCount || 0,
hasNextPage: data?.pageInfo.hasNextPage || false,
elementRef,
}
}
export default useBundledProducts
- 코드가 굉장히 심플해졌고, 페이징도 잘 됩니다! (Howard 짱! 😄)
- 오.. 끝났어.. 😎
- 테스트 중 역시 기대를 져버리지 않고 또 작동이 안 되는 부분이 발생..!
- 상품 리스트의
totalCount
가DefaultPageSize
보다 작을 경우 list 가 보이지 않는 현상이 발생하였습니다. 😔 - 원인은
fetchMore
가useQuery
와 동시에 호출 되면서 처음 호출 된useQuery
가cancel
되고 페이징이 된 상태로fetchMore
가 호출되기 때문에 빈 값의 리스트를 불러오기 때문이었습니다. - 아래와 같이
useEffect
에 조건문을 추가하면서 해결할 수 있었습니다.
const useBundledProducts = (productId: string | string[], variables: ListVariablesProps) => {
...
useEffect(() => {
// pageNumber 가 1일 때는 호출되지 않는다. (1일 때는 useQuery 자체가 호출 됨)
if (pageNumber === 1) {
return
}
// paging 을 할 수 있도록 fetchMore 를 호출한다.
fetchMoreBundledProducts({
variables: {
pageInfo: {
first: DefaultPageSize,
after: getTablePageInfoVariables(pageNumber + 1, DefaultPageSize).pageInfo.after,
},
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) {
return previousResult
}
return {
...fetchMoreResult,
edges: [...(previousResult?.edges || []), ...(fetchMoreResult?.edges || [])],
}
},
})
}, [pageNumber])
...
}
- 오.. 진짜 끝!
기타
fetchPolicy 종류
cache-first
- 쿼리 사용시 캐시 데이터를 먼저 확인한다.
- 캐시 데이터가 있다면 이를 반환하고 동작이 끝난다.
- 캐시 데이터가 없다면 서버에 요청하고 캐시 데이터를 업데이트 하고 반환한다.
- 자주 바뀌지 않는 데이터에 대해 설정하면 좋다
cache-and-network
- 쿼리 사용시 캐시 데이터를 먼저 확인한다.
- 캐시데이터가 있다면 이를 반환하지만, 캐시데이터가 있든없든 서버에 데이터를 요청하고 가져온 데이터를 한번 더 반환한다
- 화면의 빠른 응답을 위해 캐시 데이터부터 보여주고, 다시 서버에서 받아온 데이터를 보여주는 경우 사용한다.
network-only
- 무조건 서버에 데이터를 요청하여 반환하고 캐시데이터를 업데이트한다
- 캐시데이터를 확인하지 않는다
cache-only
- 캐시데이터를 반환하고 그것이 없다면 에러를 발생시킨다
no-cache
- 무조건 서버에 데이터를 요청하여 반환한다
- 캐시를 작동시키지 않는다