Apollo Client Pagination Fetching Guide

목적

  • 아폴로 클라이언트를 사용하여 페이지네이션 페칭을 가이드합니다
  • 기존 사용 방식의 리팩토링 ( 로 구현된 pagination 을 로 전환)

결과물

  • 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 생성하기

  • 기존의 상품 리스트 무한 스크롤은 를 증가시켜준 뒤, 를 이용하여 변경된 을 보내주며 요청하고 있었습니다.
  • 먼저 기존 방식을 가져와 묶음 배송 상품의 무한스크롤 기능을 위한 새로운 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 ‘ 가 제공하는 가 있는데 를 이용한다고?? 🤔’
  • Howard 의 제안에 따라 로 검색해보니 공식 문서와 예제 사이트가 나왔습니다.

공식 문서와 개인 블로그 참고하여 개선

  • 공식문서에서 알려주는 사용법입니다. (생각보다 간단해보이네!)
Apollo 공식 홈페이지의 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])

...

}
  • 역시 기대한 대로 동작하지 않았..
WhyDoesItNotWork?
  • 부분을 보니 이전 데이터와 새로 받은 데이터를 Response 로 받을 수 있고, 그 둘을 머지하여 리스트로 만드는 것 같았습니다. 를 적용하여 수정해봤습니다.
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 짱! 😄)
  • 오.. 끝났어.. 😎
  • 테스트 중 역시 기대를 져버리지 않고 또 작동이 안 되는 부분이 발생..!
  • 상품 리스트의 보다 작을 경우 list 가 보이지 않는 현상이 발생하였습니다. 😔
  • 원인은 와 동시에 호출 되면서 처음 호출 된 되고 페이징이 된 상태로 가 호출되기 때문에 빈 값의 리스트를 불러오기 때문이었습니다.
  • 아래와 같이 에 조건문을 추가하면서 해결할 수 있었습니다.
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

  • 무조건 서버에 데이터를 요청하여 반환한다
  • 캐시를 작동시키지 않는다

참조

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store