GraphQL Pagination 구현하기: Relay’s Cursor Based Connection Pattern

taeseong park
Prisma Korea
Published in
12 min readMar 14, 2020

Introducing

GraphQL의 공식페이지인 graphql.org 에서 설명하고 있는 Pagination은 Relay project가 갖고 있는 공식 스펙인 GraphQL Cursor Connections Specification과 일치하며(2020년 3월 14일 현재), Connection Specification 영역에 Relay 문서 링크를 걸어놓고 있다.

그리고 GitHub Developer API Explorer에서도 데이터 제공방식을 GraphQL API로써 제공하는데, Pagination을 Relay의 cursor connections specification을 사용하고 있다.

GraphQL Cursor Connections Specification

{
user {
id
name
friends(first: 10, after: "opaqueCursor") {
edges {
cursor
node {
id
name
}
}
pageInfo {
hasNextPage
}
}
}
}

위의 코드가 Relay문서에서 보여주고 있는 쿼리 예제이다.

여기서 주목해야 할 것은 friends 구문이고, 이 friends 구문을 Relay connection pattern에서는 Connection Model이라고 칭한다.

Pagination정보를 얻고자 할 때 이 Connection Model을 활용해야 한다.

Overview

Relay에서는 Connection Model에 대한 각 스펙에 네이밍 규칙을 정의 해 놓고 있다.

Connection객체의 이름은 항상 끝에 “Connection” 으로 끝내야 한다

Pagination에 대한 정보를 담은 객체명은 “PageInfo”로 지어야 한다

Connection Model에 대한 대략적인 Overview는 아래 그림과 같다

GraphQL Cursor Connections Specification Connection Flow overview

우선, 위의 쿼리 예제 옆에다가 Connection Model에 대한 설명을 주석으로 달아보았다.

GraphQL Cursor Connections Specification Connection Query overview

이제 각 Connection Model의 상세 스펙들에 대해서 알아보자.

Edges

Node

실질적으로 원하는 데이터가 들어 있는 Object 타입이라고 보면 된다.

예를 들어 Friends 테이블의 데이터를 얻어오고 싶을 때 Friends 테이블의 데이터는 return되는 Connection 필드 중에 edges -> node에 들어 있다고 보면 된다.

아무런 결과가 없다면 Empty Array로 리턴을 시킨다.

Cursor

이 Cursor가 Pagination의 가장 핵심적인 부분이라고 볼 수 있는데, Cursor를 기준으로 이전 데이터와 이후 데이터를 받아올 수가 있다고 이해하면 된다.

내가 과거에 서술했던 부족한 코멘트는 무시하라..

내가 첫 포스팅 당시에 썼던 내용.. 이 내용을 쓰면서도 꺼림직했는데 이 설명은 틀린 것 같다

https://graphql-kr.github.io/learn/pagination/ 페이지에서는 Cursor에 대해 아래와 같이 서술하고 있다.

커서를 offset 또는 ID로 지정한 커서 기반의 페이지네이션을 구현할 수 있으며,

커서를 사용하면 향후 페이지네이션 모델이 변경될 경우에 추가적인 유연성이 제공됩니다.

커서가 확실하고 형태를 신뢰할 수 없다는 것을 상기시켜주기 위해, base64 인코딩하는 것이 좋습니다.

base64로 인코딩한다라…

그런데 다른 예시들을 살펴 보았을 때 RDB 기준으로 createdAt 같은 정렬하기에 무난한 컬럼을 하나 잡아 String형태로 그대로 리턴하는 케이스도 있었고, 짧은 UUID같은 값(이게 base64인가..)로 쓰이는 케이스도 보았다. 흠..

이렇듯 Cursor는 Edge 안의 객체 타입으로 사용하는 것이 아니라, Connection을 위한 속성으로 쓰이며, 나름의 로직을 통해 구현해 내는 것이라고 생각한다.

사실 조금 더 스터디해서 base64로 인코딩된 Cursor 를 구현해 봐야 할 것 같다.

Arguments

쿼리 시 조건문이라고 생각하면 쉽게 이해될 것 같다.

일반적으로 검색되는 정방향으로 검색하고 싶으면 forward argument를 넣으면 되고,

역방향으로 검색하고 싶으면 backward argument를 넣으면 되겠다.

각 타입은 아래와 같다.

forward pagination arguments

  • first: non-negative integer
  • after: cursor type

backward pagination arguments

  • last: non-negative integer
  • before: cursor type

주의사항1. Edge Order

  • before argument가 들어갔을 경우: before 커서에서 가장 가까운 edge가 결과 edge 중 가장 마지막에 와야 한다.
  • after argument가 들어갔을 경우: after 커서에서 가장 가까운 edge가 결과 edge 중에 가장 먼저 나와야 한다.

실제 데이터를 넣어서 예를 들자면 이렇게 나와야 한다

before, after cursor를 입력하게 되면 리턴시켜야 할 데이터를 표시

주의사항2. first와 last를 동시에 쿼리시키게 하지 마라

Relay에서 이야기하기를 웬만해서는 first와 last argument를 동시에 받지 말라고 하고 있는데, 이는 혼란스러운 결과를 초래할 수 있다고 서술하고 있다.

실제로 GitHub Developer’s API Explorer에서는 first와 last argument를 동시에 쿼리시켰을 경우 아얘 에러를 뱉는다.

“message”: “Passing both `first` and `last` to paginate the `search` connection is not supported.”

예제 데이터를 MySQL 쿼리와 함께 Pagination Connection 구축해 보기

여태 살펴보았던 개념을 참고하여 구현 해 본 사항에 대해 서술을 해보려고 한다.

hackatalk-server의 Pull Request에서 Full code를 볼 수 있다.

예제 데이터

예제 데이터(회원 테이블: users)를 5 row 만들었다.

createdAt을 Cursor로 활용할 것이다.

argument에 따른 Edge 제어는 아래와 같이 진행한다.

Edge: Forward argument (first, after)

  1. after argument: 내림차순을 통해서 최신 가입 사용자가 가장 앞에 오도록 하고, after argument가 createdAt보다 작아야 after 이후에 가입한 사람들 리스트가 나올 것이다.
  2. first argument: 최신 가입 사용자를 내림차순으로 정렬 시킨 후 그 중에 first만큼의 row를 LIMIT 구문을 통해서 받아온다.

아래 SQL문으로 1, 2번을 표현을 할 수가 있겠다.

SELECT email, name, createdAt
FROM users
WHERE createdAt < {after}
ORDER BY createdAt DESC
LIMIT {first}

Edge: Backward argument (last, before)

  1. before argument: Forward argument에서 사용하는 SQL문과 비슷하게 before 이전에 가입된 사용자를 받도록 한다.
  2. last argument: limit을 이용해서 last의 숫자대로 받아와야 하는데 복잡한 쿼리를 쓰지 않기 위해선 createdAt의 정렬을 오름차순으로 받아온다. 그래야 limit을 이용해서 last만큼의 데이터를 받아올 수 있기 때문이다.

1, 2번에 대한 SQL문을 표현하자면 아래와 같다.

SELECT email, name, createdAt
FROM users
WHERE createdAt > {before}
ORDER BY createdAt ASC
LIMIT {last}

그리고 이 결과 데이터를 다시 내림차순 시켜서 PageInfo 객체 생성에 쓰일 수 있도록 해야 한다. 헌데 이 부분을 SQL로 하자면 서브쿼리를 써야 하는데, 한번 쿼리를 더 감싸는 것이 O(n)에서 O(n²)가 되는 느낌이라 맘에 들지 않았다 (만약 제 생각이 틀렸다면 의견 부탁드려요 ㅠㅎ)

SELECT T.email, T.name, T.createdAt
FROM
(
SELECT email, name, createdAt
FROM users
WHERE createdAt > {before}
ORDER BY createdAt ASC
LIMIT {last}
) T
ORDER BY T.createdAt DESC;

그래서 1,2번까지만 SQL을 활용하여 데이터를 받아 온 후, JS에서 createdAt을 기준으로 sort()를 하여 내림차순 정렬을 시켰다.

PageInfo 구현은 forward든 backward든 argument가 무엇이 오든 간에 리턴하는 edge데이터를 기준으로 아래 사항들을 고려하여 로직을 짠다.

  • 현재 리턴시킬 데이터 이후의 데이터가 존재하는 지(hasNextPage)
  • 현재 리턴시킬 데이터 이전의 데이터가 존재하는 지(hasPreviousPage)
  • 현재 리턴시킬 데이터의 가장 첫번째 edge의 createdAt을 구한다(startCursor)
  • 현재 리턴시킬 데이터의 가장 마지막 edge의 createdAt을 구한다(endCursor)

hasNextPagehasPreviousPage의 값을 얻어내기 위해 해당 users테이블의 가장 첫번째 데이터와 가장 마지막 데이터를 구해야 했다. 그래서 아래 SQL문을 이용하였다.

가장 첫번째 데이터: SQL

SELECT createdAt
FROM users
WHERE
users.createdAt > {before} -- or users.createdAt < {after}
AND user.verified = true
ORDER BY createdAt ASC LIMIT 1;

가장 마지막 데이터: SQL

SELECT createdAt
FROM users
WHERE
users.createdAt > {before} -- or users.createdAt < {after}
AND user.verified = true
ORDER BY createdAt DESC LIMIT 1;

PageInfo 객체 생성 util 코드

Extra: Inline Fragments에 대해서

GitHub Developers API Explorer에서 pagination 쿼리를 실습 해 보기 위해 search() query 를 입력 했었다.

그런데 edges.node를 입력하는 과정에서 id를 쳐도, 어떤 것을 쳐도 자동완성의 결과는 항상 __typename 이었다.

graphql 초보인 나는 이게 대체 무슨 상황이지? 하면서 구글링을 한 결과 … on {TYPE} 을 이용해야 원하는 데이터를 얻을 수 있다는 것을 찾았다.

Inline Fragments 구문

이 구문은 Inline Fragments라고 하며, interface나 union type을 쿼리할 때 쓰인다고 한다.

그리고 이것과 더불어서 내가 Inline Fragments를 사용하여 데이터를 리턴 받을 때, 여러 타입들이 합쳐진 데이터다 보니 이 데이터가 어떠한 스키마로부터 리턴되었는 지 모를 경우가 발생될 것이다.

이럴 때 __typename이라는 메타 필드를 이용하면 된다고 한다.

meta field: __typename

THE END

이 글에 제안이나 잘못 된 점이 있다면 언제든지 의견 남겨주세요~!

--

--