[GraphQL] Pagination

본 포스트는 공식 레퍼런스를 참고해 GraphQL을 공부하며 직접 작성한 가이드 입니다.
본 포스트는 2021년 7월 최신 버전인 v16.2를 기준으로 작성되어 있습니다.

Pagination

GrpahQL에서는 Pagintaion을 제공하고 있다. Facebook에서 만든 API Query 언어 답게 offset 기반의 Infinite Scroll 방식의 페이지네이션을 모티브로 제공하고 있다. 물론 구현에 따라 다르겠지만 서빙하는 API 응답의 형태가 그러하다. 공식 문서를 살펴보면 size, after라는 개념을 사용해 after 이후 size개수 만큼의 응답을 Edge에 반환한다. 또한 pageInfo에는 이전, 다음 페이지 여부와 요청을 쉽게 보내기 위한 Edge의 처음과 마지막 curosr를 담아준다.

먼저 페이지네이션을 위한 스키마부터 정의하자. Type Definition Factory로 웹툰 커넥션 스키마를 정의하면 된다.

1
2
3
4
5

type Query {
webToon(id: ID!): WebToon
webtoons(first: Int!, after: String): WebToonConnection @connection (for: "WebToon")
}

그 다음엔 리졸버에 쿼리를 등록하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Slf4j
@Component
@RequiredArgsConstructor
public class WebToonResolver implements GraphQLQueryResolver {
private final WebToonBO webToonBO;

public Connection<WebToon> webtoons(int first, @Nullable String after) {

List<Edge<WebToon>> edges = webToonBO.getWebtoons(first, after)
.stream()
.map(webtoon -> new DefaultEdge<>(webtoon, CursorUtil.from(webtoon.getId())))
.collect(toList());

return new DefaultConnection<>(edges, new DefaultPageInfo(
CursorUtil.getFirstCursorFrom(edges),
CursorUtil.getLastCursorFrom(edges),
Objects.nonNull(after),
edges.size() == first
));
}
}

코드를 보면 커넥션에 edgepageInfo를 넣어준다. 각 edgenodecursor를 담고있는데 공식 문서에 따르면 커서에 대한 인식과 비신뢰성을 위해 base64인코딩을 권장하고 있다. 따라서 공식 문서 스펙에 따라 CursorUtil을 구현했다. WebToonBO를 잠깐 살펴보면 다음과 같이 구현해 두었다. offsetnull인경우에는 처음부터 first개의 검색을, non null이라면 offset 이후 first개의 검색을 수행한다. 저장소의 페이지네이션 구현은 이 포스트의 주제와 벗어나니 여기까지만 설명한다.

1
2
3
4
5
6
7
public List<WebToon> getWebtoons(int first, String after) {

return Optional.ofNullable(after)
.map(CursorUtil::decodeFrom)
.map(uuid -> getWebtoonsAfter(first, uuid))
.orElseGet(() -> getWebtoons(first));
}

pageInfo에는 현재 페이지에 대한 정보를 담는다. 처음과 마지막 커서 정보, 이전 페이지 존재 여부, 다음 페이지 존재 여부에 대해 담아주면 된다. 요청에 대한 인-메모리 Mock 데이터는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
webtoons = Arrays.asList(
WebToon.builder().id(UUID.randomUUID()).title("여신강림").webToonType(WebToonType.Originals).build(),
WebToon.builder().id(UUID.randomUUID()).title("스퍼맨").webToonType(WebToonType.Originals).build(),
WebToon.builder().id(UUID.randomUUID()).title("제로게임").webToonType(WebToonType.Originals).build(),
WebToon.builder().id(UUID.randomUUID()).title("열렙전사").webToonType(WebToonType.Originals).build(),
WebToon.builder().id(UUID.randomUUID()).title("신도림").webToonType(WebToonType.Originals).build(),
WebToon.builder().id(UUID.randomUUID()).title("헬퍼").webToonType(WebToonType.Originals).build(),
WebToon.builder().id(UUID.randomUUID()).title("귀곡의 문").webToonType(WebToonType.Originals).build(),
WebToon.builder().id(UUID.randomUUID()).title("은주의 방").webToonType(WebToonType.Originals).build(),
WebToon.builder().id(UUID.randomUUID()).title("엔딩 후 서브남을 주웠다").webToonType(WebToonType.Originals).build()
);

이제 쿼리를 돌려보자. 간단하게 2개의 웹툰 정보만 페이지네이션 해서 가져오자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 웹툰 페이지네이션
query GET_WEBTOONS_PAGINATION {
webtoons(first: 2) {
edges {
cursor
node {
id
title
webToonType
}
}
pageInfo {
hasPreviousPage
hasNextPage
startCursor
endCursor
}
}
}

응답에서 pageInfo 정보와 edges에 요청한 웹툰 정보들이 나열되어 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
"data": {
"webtoons": {
"edges": [
{
"cursor": "ZjE0OGNjODAtNDA5Zi00ZjU1LWJmNjctMTYwMmM5MDg5Mzkz",
"node": {
"id": "f148cc80-409f-4f55-bf67-1602c9089393",
"title": "여신강림",
"webToonType": "Originals"
}
},
{
"cursor": "MjQ5NWFkOWEtZGQxYy00YjY1LWJhNzktZjg4ZWFjNTg1OGEw",
"node": {
"id": "2495ad9a-dd1c-4b65-ba79-f88eac5858a0",
"title": "스퍼맨",
"webToonType": "Originals"
}
}
],
"pageInfo": {
"hasPreviousPage": false,
"hasNextPage": true,
"startCursor": "ZjE0OGNjODAtNDA5Zi00ZjU1LWJmNjctMTYwMmM5MDg5Mzkz",
"endCursor": "MjQ5NWFkOWEtZGQxYy00YjY1LWJhNzktZjg4ZWFjNTg1OGEw"
}
}
}
}

이제 pageInfoendCursor를 사용해서 스퍼맨 이후의 데이터를 요청해보자.

1
2
3
4
5
6
# 웹툰 페이지네이션
query GET_WEBTOONS_PAGINATION {
webtoons(first: 2, after: "MjQ5NWFkOWEtZGQxYy00YjY1LWJhNzktZjg4ZWFjNTg1OGEw") {
...
}
}

응답에서는 Mock 데이터 내의 스퍼맨 다음에 있는 제로게임과 열랩전사 웹툰 정보가 내려오는걸 볼 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
"data": {
"webtoons": {
"edges": [
{
"cursor": "NDc4YTRjN2EtMzcxYS00ZWMxLWI0MzktYTk1MDA3NmEwMzBj",
"node": {
"id": "478a4c7a-371a-4ec1-b439-a950076a030c",
"title": "제로게임",
"webToonType": "Originals"
}
},
{
"cursor": "NmNmNWM5MTctZTc4My00ZTE3LWJkOGItY2MyNDFlNWNiODRm",
"node": {
"id": "6cf5c917-e783-4e17-bd8b-cc241e5cb84f",
"title": "열렙전사",
"webToonType": "Originals"
}
}
],
"pageInfo": {
"hasPreviousPage": true,
"hasNextPage": true,
"startCursor": "NDc4YTRjN2EtMzcxYS00ZWMxLWI0MzktYTk1MDA3NmEwMzBj",
"endCursor": "NmNmNWM5MTctZTc4My00ZTE3LWJkOGItY2MyNDFlNWNiODRm"
}
}
}
}

Repository

모든 가이드의 예제 코드는 SongHayoung/springboot-graphql-tutorial에서 확인할 수 있습니다.

Author: Song Hayoung
Link: https://songhayoung.github.io/2021/07/31/GraphQL/graphql-12/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.