Graphql Pagination
Table of Contents
1 공식문서
1.1 Pagination
GraphQL은 일반적으로 개체 집합 간의 관계를 탐색하기 위해 사용한다. GraphQL에서는 이런 관계 표현을 위한 여러 방법이 존재한다.
1.2 Plurals
객체간 연결(connection)을 노출하는 가장 쉬운 방법은 plurals
타입의 필드를 리턴하는 것이다.
예를 들어, "R2-D2"
의 친구 목록을 얻으려면 모든 친구를 요청할 수 있다.
Figure 1: Graphql Pagination Plurals
1.3 Slicing
클라이언트는 단순히 모든 친구가 아닌 처음 2명만 가져오라고 요청하고 싶을 수 있다.
{ hero { name friends(first:2) { name } } }
하지만 처음 2명만 가져왔다면, 다음 2명도 조회하고 싶을 것이다. 우리는 결국 페이징 처리를 고민하게 된다.
1.4 Pagination and Edges
우리는 페이지네이션을 위한 몇가지 방법을 가지고 잇다.
friends(first:2, offset:2)
: 다음 2명을 요청한다.friends(first:2, after:$friendId)
:$friendId
다음 2명friends(first:2, after:$friendCursor)
: 마지막 아이템에서 마지막 커서(cursor
)를 가져와서 페이지처리를 한다.
그리고 그래프큐엘은 3번인 cursor-based pagination
이 가장 강력한 디자인이라고 생각했다.
cursor
가 불투명하다면(구현이 명확하지 않다는 말인듯) 위에서 설명한 offset
이나 friendId
방식을 커서방식으로 구현할 수 있다(커서를 offset
, id
로 바꾸면 되는 것이다).
그리고 커서방식은 미래에 pagination 모델이 변경되었을 때도 유연함을 자랑한다.
그런데 문제가 있다. 어떻게 커서를 구할까? 확실히 커서는 User
타입에 존재하지않는다.
이건 connection
의 연결 속성이지, 객체의 속성은 아니다.
그러므로 우리는 간접참조를 위한 새로운 계정을 원할 것이다.
friends
필드는 edge
리스트를 제공하고, edge
는 cursor
, node
를 제공하는 것이다.
{ hero { name friends(first:2) { edges { node { name } cursor } } } }
edge
의 개념은 객체 중 하나가 아니라 edge
에 특정정보가 있는 경우에도 유용하다.
예를 들어 API에서 friendship time
을 노출하고 싶다면 edge
에 두는 것이 자연스러운 위치입니다.
1.4.1 End-of-list, counts, and Connections#
이제 커서를 이용하는 커넥션(connection)을 받아 페이지처리를 할 수 있게 되었다고 하자. 커넥션의 마지막은 어디일까? 아마 마지막까지 쿼리를 계속 날려서 빈값이 리턴될 때까지 가봐야 알 것이다.
만약에 '이곳이 마지막 페이지'라는 것을 알려준다면 우리는 마지막 요청을 할 필요가 없게된다. 마찬가지로, 커넥션 자체에 대한 추가 정보를 알고 싶다면 어떻게 해야 할까? 예를 들어, 'R2-D2에는 총 친구가 몇 명 있을까?' 같은 질문을 말이다.
이걸 해결하기 위해 frieds
필드는 connection object
를 리턴할 수 있다.
이 커넥션 개체는 edge
에 대한 필드 뿐만 아니라 다른 정보도 가질 수 있다. (토탈카운트나 다음 페이지 존재여부)
하여 마지막 쿼리는 다음과 같다.
{ hero { name friends(first:2) { totalCount edges { node { name } cursor } pageInfo { endCursor hasNextPage } } } }
PageInfo
를 보자 이곳에는 endCursor
, startCursor
둘다 올 수 있다.
1.5 Complete Connection Model
물론 단순히 plurals
를 리턴하는 방식보다 많이 복잡하다.
하지만 이 디자인을 접목하면서 클라이언트에선 몇가지 능력이 생긴다.
- 리스트를 페이지처리할 수 있다.
- connection 자체에게 정보를 요청할 수 있다.
totalCount
,PageInfo
등을 말한다. edge
자체 정보를 요청할 수 있다.cursor
,friendship
을 말한다.- 백엔드는 페이지처리방식을 바꿀 수 있다. 이 커서가 불투명하게 노출된다면!
이런 것들을 충족시키기 위해, 예시 스키마에 friendsConnection
을 추가했다. 이 녀석은 위에서 말한 모든 컨셉을 아우른다.
{ hero { name friendsConnection(first:2 after:"Y3Vyc29yMQ==") { totalCount edges { node { name } cursor } pageInfo { endCursor hasNextPage } } } }
리턴값
{ "data": { "hero": { "name": "R2-D2", "friendsConnection": { "totalCount": 3, "edges": [ { "node": { "name": "Han Solo" }, "cursor": "Y3Vyc29yMg==" }, { "node": { "name": "Leia Organa" }, "cursor": "Y3Vyc29yMw==" } ], "pageInfo": { "endCursor": "Y3Vyc29yMw==", "hasNextPage": false } } } } }
1.6 Connection Specification
이런 패턴의 일관된 구현을 보장하기 위해 Relay 프로젝트에서는 커서기반연결패턴 GraphQL API 공식 스펙이 있다.
2 relay's GraphQL Cursor Connections Specification
2.1 Reserved Type
Cursor Connection Spec을 준수하는 GraphqlQL 서버는 페이지처리를 지원하기 위해 예약된 특정 타입과 이름을 가져야한다. 특히 이 Spec은 다음 Type에 대한 지침(guidelines)를 만들어 놓았다.
- 이름이
Connection
으로 끝나는object
PageInfo
가 이름인object
2.2 Connection Types
"Connection"
으로 끝나는 모든 타입은 Connection Type
으로 간주한다.
Connection Type
은 object
여야한다. 또한 GraphQL Specification의 Type System
섹션에 정의되어 있어야 한다.
2.2.1 Fields
Connection Type
은 edges
와 PageInfo
라는 필드를 가져야 한다.
이것들은 connection에 연관된 추가 필드를 가질 수 있다. (스키마 디자이너가 적합하다고 생각하는)
2.2.2 Introspection
만약 ExceptionConnection
이 타입시스템에 존재한다면, 이녀석은 connection일 것이다, 왜냐하면 "Connection"이라는 이름으로 끝나기 때문이다.
만약 이 커넥션의 edge type이 ExampleEdge
라는 이름을 가진다고하자.
서버가 이 구현을 Spec에 따라 잘 구현했다면 하여 아래 Introspection 쿼리를 허용하고, 제공된 응답을 반환한다.
{ __type(name: "ExampleConnection") { fields { name type { name kind ofType { name kind } } } } }
리턴은 다음처럼
{ "data": { "__type": { "fields": [ // May contain other items { "name": "pageInfo", "type": { "name": null, "kind": "NON_NULL", "ofType": { "name": "PageInfo", "kind": "OBJECT" } } }, { "name": "edges", "type": { "name": null, "kind": "LIST", "ofType": { "name": "ExampleEdge", "kind": "OBJECT" } } } ] } } }
2.3 Edge Types
connection type의 edges
필드를 스펙에서 Edge Type으로 간주된다. (리스트로 리턴해야함.)
Edge Type은 GraphQL스펙의 "Type System" 섹션에 정의된 Object
이어야 한다.
2.3.1 Fields
Edge Type은 node
, cursor
라는 필드가 있어야 한다.
또한 edge
와 관련있는 추가적인 필드가 있을 수 있다.
- Node
edge
타입은node
필드를 가져야 한다. 이 필드는Scalar
,Object
,Interface
,Union
혹은 이것들을 리턴하는Non-null wrapper
이다. 리스트를 반환하면 안된다. - Cursor
Edge Type은
cursor
를 가진다. 이 필드는 문자열로 직렬화되는 타입을 반환한다. (Non-Null이건, 커스텀 스칼라건 상관없다.) 어떤 타입을 반환하건, 이 스펙(Spec)의 나머지 부분에서는cursor type
이라고 부른다.이 필드의 결과는 클라이언트에서 불투명한 것으로 간주되어야 한다. 그리고 아래 "Argument" 섹션에 설명된대로 서버에 다시 전달되어야 한다.
2.3.2 Introspection
ExampleEdge
가 Example
object를 리턴하는 우리 스칼라타입의 edge type 이라고 하자.
서버가 다음 구현을 Spec에 맞춰 잘 만들었다면 다음의 introspection 쿼리를 허용 및 제공되는 응답을 리턴한다.
{ __type(name: "ExampleEdge") { fields { name type { name kind ofType { name kind } } } } }
리턴은
{ "data": { "__type": { "fields": [ // May contain other items { "name": "node", "type": { "name": "Example", "kind": "OBJECT", "ofType": null } }, { "name": "cursor", "type": { // This shows the cursor type as String!, other types are possible "name": null, "kind": "NON_NULL", "ofType": { "name": "String", "kind": "SCALAR" } } } ] } } }
2.4 Argument
Connection Type
을 리턴하는 필드는 반드시 정방향 혹은 역방향 페잉지네이션을 인자로 포함해야한다. (혹은 둘다)
이 페이지네이션 인자는 클라이언트가 edge
집합을 잘라서 받을 수 있게 한다.
2.4.1 Forward pagination arguments
정방향 페이지네이션을 사용하려면, 두 인자가 필수다.
first
는 음수가 아닌 숫자여야 한다.after
는cursor type
을 취해야 한다.
서버는 두 인자를 사용하여 connection에 의해 after
cursor 뒤의 edge
를 리턴하고,
first
edge에 맞는 갯수를 리턴함.
2.4.2 Backward pagination arguments
역방향 페이지네이션을 사용하려면, 두 인자가 필수다.
last
는 음수가 아닌 숫자여야 한다.before
는cursor type
을 취해야 한다.
2.4.3 Edge order
중요한 점, edges
의 순서는 first|after
를 쓰건 last|before
를 쓰건 동일해야한다.
역방향 조회를 한다고 해서, 순서가 역방향이 저절로 되면 안된다.
좀 더 설명하면,
before: cursor
가 쓰인다면 커서의 가장 가까운edge
는 리턴하는edges
의last
이다.after: cursor
가 쓰인다면 커서의 가장 가까운edge
는 리턴하는edges
의first
이다.
2.5 Pagenation Algorithm
어떤 edge
가 리턴할지 결정하려면, connection은 before
, after
커서를 평가해서 edges
를 필터링한다.
그리고 first
로 edges
를 자른다음 last
로 edges
를 자른다.
first
, last
를 동시에 사용하는 것은 권장되지 않는다.
쿼리와 결과가 헷갈릴 수 있다.
PageInfo
섹션에서 자세히 다룰 것이다.
공식적으로 적으면
EdgesToReturn(allEdges, before, after, first, last)
:
edges
를ApplyCursorsToEdges(allEdges, before, after)
의 결과라 하자.first
가 인자로 있을 때 (설정된 경우)first
가 0보다 작을 때 : 예외를 던진다.- 만약
edges
가first
보다 큰 length를 가진 경우 :first
길이가 되도록, 뒷부분을 잘라낸다.
last
가 인자로 있을 때 (설정된 경우)last
가 0보다 작을 때 : 예외를 던진다.- 만약
edges
가last
보다 큰 length를 가진 경우 :last
길이가 되도록, 앞부분을 잘라낸다.
edges
를 리턴한다.
ApplyCursorsToEdges(allEdges, before, after)
:
allEdges
로edges
를 초기화한다.after
가 설정된 경우afterEdge
는 커서가after
인자와 동일한edges
의edge
다.afterEdge
가 존재한다면 :afterEdge
를 포함한 이전edges
의 요소들을 제거한다.
before
가 설정된 경우beforeEdge
는 커서가before
인자와 동일한edges
의edge
다.beforeEdge
가 존재한다면 :beforeEdge
포함한 이후edges
의 요소들을 제거한다.
edges
를 리턴한다.
2.6 PageInfo
서버는 반드시 PageInfo
타입을 제공해야 한다.
2.6.1 Fields
PageInfo
는 hasPreviousPage
와 hasNextPage
필드를 반드시 가진다. 둘 다 non-null bolean
이다.
또한 startCursor
, endCursor
필드를 가져야한다. 둘다 non-null opague string
을 리턴한다.
hasPreviousPage
는 클라이언트 인자에 의해 정의된 집합(조건에 부합하는 edges)의 이전( previous
)에 edges
가 더 있는지 알려준다.
클라이언트에서 last|before
요청쿼리로 조회할 때, 이전 값이 존재하면 true
아니면 false
를 리턴한다.
또한 first|after
의 경우는 after
이전 값이 존재하면서 이것을 효율적으로 수행할 수 있을 때 true
아니면 false
를 리턴한다.
좀 더 공식적으로는
HasPreviousPage(addEdges, before, after, first, last)
:
last
가 설정되어 있으면edges
를ApplyCursorsToEdges(allEdges, before, after)
의 결과라고 하자.- 만약
edges
가last
보다 많은 요소를 가진다면true
, 아니면false
after
가 설정되어 있으면- 서버가 효율적으로
after
이전에 요소가 존재하는지 결정 할 수 있다면true
를 반환한다.
- 서버가 효율적으로
false
를 리턴한다.
hasNextPage
는 클라이언트 인자에 의해 정의된 집합(조건에 부합하는 edges) 이후에 더 많은 edge가 있는지 여부를 리턴.
first/after
를 쓸 때, 서버에서 추가 edges
가 존재하면 true
아니면 false
를 리턴한다.
last/before
를 쓸 때, 서버에서 before
기준으로 추가 edges
가 존재하고, 효율적으로 수행할 수 있으면 true
아니면 false
를 리턴한다.
좀 더 공식적으로는
HasNextPage(allEdges, before, after, first, last)
:
first
가 설정되어 있으면edges
를ApplyCursorsToEdges(allEdges, before, after)
의 결과라고 하자.- 만약
edges
가first
보다 많은 요소를 가진다면true
, 아니면false
before
가 설정되어 있으면- 서버가 효율적으로
before
이전에 요소가 존재하는지 결정 할 수 있다면true
를 반환한다.
- 서버가 효율적으로
false
를 리턴한다.
만약 first
, last
가 동시에 있으면 위 법칙에 따라 리턴할 것이다. 하지만 페이지처리방식이 명확해 보이지 않을 것이다.
그러므로 동시에 쓰는 것은 권장하지 않는다.
startCursor
와 endCursor
는 edges
의 first
, last
노드를 각각 말한다.
2.6.2 Introspection
위 스펙을 잘 구현했다면 아래 처럼 수행될 것
{ __type(name: "PageInfo") { fields { name type { name kind ofType { name kind } } } } }
다음처럼 리턴
{ "data": { "__type": { "fields": [ // May contain other fields. { "name": "hasNextPage", "type": { "name": null, "kind": "NON_NULL", "ofType": { "name": "Boolean", "kind": "SCALAR" } } }, { "name": "hasPreviousPage", "type": { "name": null, "kind": "NON_NULL", "ofType": { "name": "Boolean", "kind": "SCALAR" } } }, { "name": "startCursor", "type": { "name": null, "kind": "NON_NULL", "ofType": { "name": "String", "kind": "SCALAR" } } }, { "name": "endCursor", "type": { "name": null, "kind": "NON_NULL", "ofType": { "name": "String", "kind": "SCALAR" } } } ] } } }