hackatalk-mobile React-Native 프로젝트에 graphql relay-style cursor pagination 적용하기
Pagination in hackatalk-mobile
hackatalk-mobile 프로젝트에서 현재 구현된 Apollo Client를 이용한 graphql relay-style cursor pagination 에 대하여 공유를 드리고자 합니다.
현재 SearchUser 화면에 구현이 되어 있고, 백엔드 프로젝트인 hackatalk-server에서 추가 Pagination에 대한 API가 구현되는대로 Pagination이 필요한 다른 곳에도 도입을 할 예정입니다.
페이지네이션(pagination)
offset-based pagination
페이지네이션 하면 일단 예전부터 사용되어 왔던 1페이지부터 10페이지 버튼이 하단에 있는 게시판이 떠오릅니다.
이 방식은 보통 offset 기반의 페이지네이션(offset-based pagination)기법을 사용하고 있습니다.
offset은 보통 몇 번째 페이지인 지 지정하는 위치 값이 되겠죠, 그러면 백엔드에서 쿼리할 때는 아래 단계를 거치게 될 겁니다.
- 게시판 테이블 값을 전부 스캔한다
- offset값 이후의 데이터를 잘라낸다
- 잘라낸 데이터 중 맨 앞에서부터 한 페이지 당 표시 할 게시물 갯수만큼 잘라서 리턴한다
1번 과정이 있다는 것은 대용량 데이터 처리 시에 매우 느린 퍼포먼스가 나오게 될 것입니다. 하지만 이러한 이슈에 관련해서는 오래된 이슈이기 때문에 인덱스 처리를 하는 등의 성능 최적화 작업을 해서 기존에 쓰던 곳에서는 잘 사용을 하고 있을 겁니다.
cursor-based pagination
그치만 SNS에서 주로 쓰이는 infinite scroll pagination등의 방식을 적용하게 되었을 때 offset-based pagination을 쓰게 되면 아래와 같은 문제가 나올 수 있습니다.
- 페이지 로드 후 아이템이 새로 추가된 시점에 다음 페이지로 넘어갔을 때 이전 페이지의 일부 데이터가 또 조회됨
cursor-based pagination에서 이야기 하고 있는 cursor란 데이터를 가리키고 있는 포인터라고 생각하면 쉬울 것 같습니다.
실시간으로 휙휙 변화는 데이터들 목록을 불러올 때, cursor값을 기준으로 삼아 cursor 다음의 데이터를 가져오는 쿼리를 날림으로써 기존 offset-based pagination이 갖고 있던 중복 데이터를 쿼리하지 않을 수 있게 됩니다.
그래서 백엔드에서 cursor를 정할 때에는 DB의 unique하고, 순서가 있는 컬럼을 하나 골라서 구현을 해야 합니다.
그래서 저희 hackatalk-mobile에서는 infinite scroll pagination을 구현하고 있기 때문에 cursor-based pagination을 구현하기로 합니다.
Apollo Client 채택 이유
React에 GraphQL을 사용하게 해 주는 라이브러리로써 Apollo와 Relay라는 두 가지의 라이브러리가 존재합니다.
hackatalk-mobile 프로젝트에서는 Apollo Client를 채택 중인데, 이유는 아래와 같습니다.
엄격한 스키마 규율 준수를 위한 오버엔지니어링 회피
Relay에서는 스키마 규율이 엄격해서 쿼리문에는 끝에 {Module}Query, Mutation에는 {Module}Mutation 등의 네이밍 규칙을 꼭 지켜야 합니다.
프론트에서 백엔드의 graphql 쿼리 스키마 구조와 똑같이 맞추기 위해 schema.graphql파일을 따로 만들어서 관리를 해야 할 수 있습니다.
그렇지 않으면 Relay 컴파일러에서 에러를 유발할 수 있다고 합니다. 자칫 오버엔지니어링이 발생 될 수가 있다는 것이겠죠.
그치만 Apollo client는 이러한 스키마 네이밍 규율에 대해서 관대하고, 프론트에서 schema.graphql을 따로 만들어서 스키마 구조를 맞춰야 하는 강제성도 없습니다. 그리고 커뮤니티 규모가 Relay보다 훨씬 큽니다. 이것이 저희가 Apollo client를 택한 이유입니다.
그리고 Apollo와 Relay를 비교한 좋은 포스팅을 발견 해서 한번 공유 해 드리겠습니다
Relay Style Cursor Pagination 의 이해
Relay-style cursor pagination을 택한 이유
Apollo cursor pagination은 스키마 구조는 간단하고 직관적입니다.
그렇지만 결과값 중 맨 처음 전 데이터가 있는지와 맨 끝 이후의 데이터가 있는 지에 대한 확인이 불가하다는 점, cursor 기준으로 이전 데이터를 가져오는 argument가 없다는 점이 눈에 띕니다.
이렇듯 Relay-style cursor pagination은 페이징 처리에 대하여 전후 방향, 그리고 페이지 내용의 맨 끝과 맨 마지막의 상태를 알 수 있는 pageInfo 타입이 들어간 Connection model의 개념이 잘 녹아 들어 있습니다.
GraphQL 공식 페이지인 graphql.org의 pagination 페이지는 위에서 서술한 페이지네이션에서 발생할 수 있는 종합적인 상황을 고려한 Connection model design을 서술하고 있는데, 이것이 Relay-style cursor pagination이 구현된 모델과 거의 같다고 볼 수 있습니다.
GitHub Developer API Explorer 에서 또한 pagination 을 Relay-style cursor pagination으로 되어 있는 것을 확인 할 수 있습니다.
Relay-style cursor pagination 사용 예
아래 GraphQL 쿼리문은 relay-style cursor pagination의 예입니다.
query {
products(first: 10, after: "cursor") {
edges {
cursor
node {
id
name
}
}
pageInfo {
hasNextPage
}
}
}
10개의 데이터를 얻기 위한 쿼리문인데요, “first”인수를 사용하여 수행됩니다. offset-based pagination의 “limit”과 유사하다고 보시면 됩니다.
페이징 처리는 “after”인수를 사용하여 수행되며, 결과는 “cursor” 다음에 있을 것으로 예상되는 “cursor”를 전달합니다.
또한 cursor pagination에는 총 페이지 수 개념이 없기 때문에 더 많은 결과가 있는지 클라이언트에게 알려주는 pageInfo type이 있으며, “hasNextPage”를 요청해서 확인 할 수가 있습니다.
Relay-style cursor pagination에 대한 더 자세한 내용은 graphql-seoul 미디움 채널에 포스팅 한 바가 있으니 관심 있으신 분들은 한번 열람 해 보시면 좋겠습니다.
또한 Apollo Blog에는 facebook 엔지니어가 기고한 포스팅이 있는데, 여기서는 graphql cursor pagination 에서 자주 나오는 개념인 Connection에 대하여 탄생배경부터 그림과 함께 네이밍 규칙에 대하여 설명하고 있는데, 같이 참고 하시면 좋을 것 같습니다.
Apollo Client 사용하기
세팅하기
React-Native 프로젝트에 Apollo client 세팅을 해 주는 방법에 대해서는 이전에 이미 react-native-seoul 미디움 채널에 상세하게 포스팅이 되어 있으니 읽어 보시길 권장드립니다.
Graphql 쿼리하기
@apollo/react-hooks 라이브러리에 있는 useQuery를 이용하여 쿼리를 합니다.
실제 구현 코드는 아래 링크에서 확인하실 수 있습니다.
useQuery에 대한 Apollo API 문서를 보시면 더 다양한 option, result 속성 들을 확인 하실 수 있습니다.
fetchMore 활용하기
앞선 쿼리에서 받아온 데이터의 마지막까지 다 열람하고 그 다음 데이터를 받기 위해 사용하게 될 메소드가 바로 fetchMore 입니다. 즉 pagination을 가능케 해 주는 메소드죠.
fetchMore 메소드에 들어갈 파라미터 타입과 리턴 타입은 아래와 같습니다.
({ query?: DocumentNode, variables?: TVariables, updateQuery: Function }) => Promise<ApolloQueryResult>
저희는 여기서 커서 기반의 argument를 넣는 variables, 새로운 결과값을 apollo client에 업데이트 시켜주는 updateQuery 를 활용하여 다음 데이터를 받고자 하는 pagination쿼리를 업데이트 시켜 주었습니다.
참고 url -> Apollo Docs: relay-style cursor pagination (fetchMore 사용 예제 포함)
FlatList를 이용한 Scroll Pagination 구현하기
Pagination을 위하여 FlatList의 아래 prop들을 활용하였습니다.
- onEndReached
- onEndReachedThreshold
- scrollEventThrottle
onEndReached
FlatList 속성 중에 renderItem 이라는 속성을 이용하여 리스트들을 컴포넌트로 렌더링을 하게 되는데,
이 리스트가 스크롤되면서 특정 offset에 도달 되었을 때 실행 되게 할 수 있는 로직입니다.
여기에서 Apollo client의 fetchMore를 활용한 pagination 로직이 들어 가게 됩니다.
해당 로직을 실행 시키게 하는 속성값은 바로 아래 서술 하게 될 onEndReachedThreshold에서 조절 할 수 있습니다.
onEndReachedThreshold
onEndReachedThreshold를 간단히 설명하자면, 스마트폰 화면의 맨 끝 부분과 실제 화면에 보이지 않고 있는 리스트 컨텐츠의 끝 부분 사이가 얼마나 떨어져 있는 지 측정하여 내가 넣은 값을 만족 시키면 onEndReached를 실행시켜 주는 속성입니다.
onEndReachedThreshold 라는 것은 정확히 어떤 조건에서 onEndReached 를 실행 시키는 지 확실하게 알고 싶어서 이 기회에 한번 실습을 통하여 알아 보았습니다.
expo의 snack으로 직접 코딩하며 실습 해 본 것인데, threshold 변수의 값을 조절 해 보시면서 어느 시점에 아이템이 추가 로드 되는 지, offset과 height로그를 확인 하실 수 있습니다.
onEndReachedThreshold의 값을 1로 줘 봅니다.
이 1이라는 게 의미하는 것은 배수이며, 보통 0에서 1 사이의 값을 넣는 게 일반적인데, 어떤 것을 대상으로 이 배수를 곱할까요?
실습을 통하여 보면 FlatList의 height를 대상으로 하는 것 같습니다. 그 증거는 위에 첨부된 그림에서 보이시는 `window-(TopBar+statusBar)`. 즉 FlatList의 height값이 `onEndReached`가 실행 되었을 당시에 뱉어내는 `distanceFromEnd`의 값과 같기 때문입니다. 수기로 스크롤을 내리다 보니까 빠른 속도로 스크롤을 내리면 값이 일치하지 않은 상태로 로그가 찍히기도 하네요; ㅎ
그래서 실제 리스트의 가장 마지막 밑에 부분과 스마트폰 기기의 밑 부분의 간격이 (flatlist의 height) * (onEndReachedThreshold의 값)이 되는 순간 onEndReached 로직이 발동 되는 원리라는 것을 실습과 도식화를 통해서 새삼 다시 한 번 확인하게 되었습니다
만약 onEndReachedThreshold의 값을 아무것도 넣지 않으면 기본값으로 0.5가 들어가게 되니 참고하세요.
scrollEventThrottle
scrollEventThrottle prop은 제가 개발 중에 onEndReached로직이 2번 연속으로 실행이 되는 경우를 발견해서 구글링을 하다가 우연히 발견 한 속성입니다.
scrollEventThrottle은 문서에 따르면 ScrollView 컴포넌트에 내용이 존재하는데, FlatList의 prop들은 ScrollView의 prop을 상속 받았기 때문에 사용이 가능한 것입니다.
상속 받았다는 내용은 아래 React Native FlatList 페이지 링크를 걸어 두었습니다.
Inherits ScrollView Props, unless it is nested in another FlatList of same orientation.
scrollEventThrottle 기본값은 ‘0’ms이며, 보기가 스크롤 될 때마다 한 번만 스크롤 이벤트가 전송됩니다. 숫자가 높을수록 성능은 향상되지만 정밀도는 떨어진다고 합니다.
보통 16ms 으로 값을 넣는 예제들을 심심찮게 찾아 볼 수 있었는데, 이유는 bridge를 통해 전송되는 정보의 양으로 인해 스크롤 성능 문제가 발생할 수 있기 때문에 최소한의 ms단위인 16ms를 사용하는 것이라고 합니다.
저희 프로젝트에서는 굳이 빠르게 쿼리 할 필요가 없다고 느껴서 넉넉하게 500ms로 넣었습니다 ㅎ
참고로 이 속성은 iOS에서만 작동됩니다.
마무리
Infinite Scroll Pagination 구현을 위하여 Graphql의 relay-style cursor pagination에 대해 알아 보았고,
React-Native 프로젝트에서 GraphQL을 사용하게 해 주는 Relay와 Apollo에 대해 간단히 알아 보았으며,
Apollo Client를 통하여 실제 GraphQL 쿼리 사용과 Pagination방법,
FlatList의 onEndReached, onEndReachedThreshold, scrollEventThrottle prop에 대해 알아보았습니다.
이렇게 문서로 정리를 하다 보니까 내가 머릿속으로 어설프게 알고 있던 부분에 대한 궁금증이 많이 생기고, 파헤쳐 가며 알아내다 보니 가려운 부분을 긁는 느낌이 들어서 복잡했던 머릿속이 시원해 지는 느낌도 듭니다. 제 개인적으로는요..ㅎㅎ
이렇듯 저희가 커뮤니티에서 진행 중인 프로젝트 관련, 아니면 React-Native와 관련 된 공유하고자 하는 내용이 있으시다면 누구든 얼마든지 저희 react-native-seoul 미디움 채널에 포스팅을 남겨주시고, 같이 지식 공유하는 소통의 장이 되었음 좋겠습니다.
아.. 저희 리액트네이티브서울 커뮤니티 페이지에 올라왔던 질문 중에 이 주제에 관련 된 질문은 잘 해결 되었는지 궁금하네요.. ㅎ
추가할 사항 있다면 내용 업데이트 하겠습니다
감사합니다.