Graphql Pagination

Table of Contents

1 공식문서

1.1 Pagination

GraphQL은 일반적으로 개체 집합 간의 관계를 탐색하기 위해 사용한다. GraphQL에서는 이런 관계 표현을 위한 여러 방법이 존재한다.

1.2 Plurals

객체간 연결(connection)을 노출하는 가장 쉬운 방법은 plurals 타입의 필드를 리턴하는 것이다. 예를 들어, "R2-D2" 의 친구 목록을 얻으려면 모든 친구를 요청할 수 있다.

plurals.png

Figure 1: Graphql Pagination Plurals

1.3 Slicing

클라이언트는 단순히 모든 친구가 아닌 처음 2명만 가져오라고 요청하고 싶을 수 있다.

{
  hero {
    name
    friends(first:2) {
      name
    }
  }
}

하지만 처음 2명만 가져왔다면, 다음 2명도 조회하고 싶을 것이다. 우리는 결국 페이징 처리를 고민하게 된다.

1.4 Pagination and Edges

우리는 페이지네이션을 위한 몇가지 방법을 가지고 잇다.

  1. friends(first:2, offset:2) : 다음 2명을 요청한다.
  2. friends(first:2, after:$friendId) : $friendId 다음 2명
  3. friends(first:2, after:$friendCursor) : 마지막 아이템에서 마지막 커서( cursor )를 가져와서 페이지처리를 한다.

그리고 그래프큐엘은 3번인 cursor-based pagination 이 가장 강력한 디자인이라고 생각했다. cursor 가 불투명하다면(구현이 명확하지 않다는 말인듯) 위에서 설명한 offset 이나 friendId 방식을 커서방식으로 구현할 수 있다(커서를 offset , id 로 바꾸면 되는 것이다). 그리고 커서방식은 미래에 pagination 모델이 변경되었을 때도 유연함을 자랑한다.

그런데 문제가 있다. 어떻게 커서를 구할까? 확실히 커서는 User 타입에 존재하지않는다. 이건 connection 의 연결 속성이지, 객체의 속성은 아니다.

그러므로 우리는 간접참조를 위한 새로운 계정을 원할 것이다. friends 필드는 edge 리스트를 제공하고, edgecursor , 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 Typeobject 여야한다. 또한 GraphQL Specification의 Type System 섹션에 정의되어 있어야 한다.

2.2.1 Fields

Connection TypeedgesPageInfo 라는 필드를 가져야 한다. 이것들은 connection에 연관된 추가 필드를 가질 수 있다. (스키마 디자이너가 적합하다고 생각하는)

  1. Edges

    Connection Typeedges 라는 필드를 가져야 한다. 이 필드는 edge 타입을 래핑한 리스트 타입을 리턴해야 한다. 여기서 edge 타입의 요구사항(requirements)는 아래 Edge Types 라는 섹션에 적혀있다.

  2. PageInfo

    "ConnectionType"은 pageInfo 라는 필드를 가져야 한다. 이 필드는 PageInfo 개체를 non-null 로 리턴해야 한다. 이 녀석의 정의는 아래 PageInfo 섹션에서 설명할 것.

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 와 관련있는 추가적인 필드가 있을 수 있다.

  1. Node

    edge 타입은 node 필드를 가져야 한다. 이 필드는 Scalar, Object, Interface, Union 혹은 이것들을 리턴하는 Non-null wrapper 이다. 리스트를 반환하면 안된다.

  2. Cursor

    Edge Type은 cursor 를 가진다. 이 필드는 문자열로 직렬화되는 타입을 반환한다. (Non-Null이건, 커스텀 스칼라건 상관없다.) 어떤 타입을 반환하건, 이 스펙(Spec)의 나머지 부분에서는 cursor type 이라고 부른다.

    이 필드의 결과는 클라이언트에서 불투명한 것으로 간주되어야 한다. 그리고 아래 "Argument" 섹션에 설명된대로 서버에 다시 전달되어야 한다.

2.3.2 Introspection

ExampleEdgeExample 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 는 음수가 아닌 숫자여야 한다.
  • aftercursor type 을 취해야 한다.

서버는 두 인자를 사용하여 connection에 의해 after cursor 뒤의 edge 를 리턴하고, first edge에 맞는 갯수를 리턴함.

2.4.2 Backward pagination arguments

역방향 페이지네이션을 사용하려면, 두 인자가 필수다.

  • last 는 음수가 아닌 숫자여야 한다.
  • beforecursor type 을 취해야 한다.

2.4.3 Edge order

중요한 점, edges 의 순서는 first|after 를 쓰건 last|before 를 쓰건 동일해야한다. 역방향 조회를 한다고 해서, 순서가 역방향이 저절로 되면 안된다.

좀 더 설명하면,

  • before: cursor 가 쓰인다면 커서의 가장 가까운 edge 는 리턴하는 edgeslast 이다.
  • after: cursor 가 쓰인다면 커서의 가장 가까운 edge 는 리턴하는 edgesfirst 이다.

2.5 Pagenation Algorithm

어떤 edge 가 리턴할지 결정하려면, connection은 before, after 커서를 평가해서 edges 를 필터링한다. 그리고 firstedges 를 자른다음 lastedges 를 자른다.

first, last 를 동시에 사용하는 것은 권장되지 않는다. 쿼리와 결과가 헷갈릴 수 있다. PageInfo 섹션에서 자세히 다룰 것이다.

공식적으로 적으면

EdgesToReturn(allEdges, before, after, first, last) :

  1. edgesApplyCursorsToEdges(allEdges, before, after) 의 결과라 하자.
  2. first 가 인자로 있을 때 (설정된 경우)
    1. first 가 0보다 작을 때 : 예외를 던진다.
    2. 만약 edgesfirst 보다 큰 length를 가진 경우 : first 길이가 되도록, 뒷부분을 잘라낸다.
  3. last 가 인자로 있을 때 (설정된 경우)
    1. last 가 0보다 작을 때 : 예외를 던진다.
    2. 만약 edgeslast 보다 큰 length를 가진 경우 : last 길이가 되도록, 앞부분을 잘라낸다.
  4. edges 를 리턴한다.

ApplyCursorsToEdges(allEdges, before, after) :

  1. allEdgesedges 를 초기화한다.
  2. after 가 설정된 경우
    1. afterEdge 는 커서가 after 인자와 동일한 edgesedge 다.
    2. afterEdge 가 존재한다면 : afterEdge 를 포함한 이전 edges 의 요소들을 제거한다.
  3. before 가 설정된 경우
    1. beforeEdge 는 커서가 before 인자와 동일한 edgesedge 다.
    2. beforeEdge 가 존재한다면 : beforeEdge 포함한 이후 edges 의 요소들을 제거한다.
  4. edges 를 리턴한다.

2.6 PageInfo

서버는 반드시 PageInfo 타입을 제공해야 한다.

2.6.1 Fields

PageInfohasPreviousPagehasNextPage 필드를 반드시 가진다. 둘 다 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) :

  1. last 가 설정되어 있으면
    1. edgesApplyCursorsToEdges(allEdges, before, after) 의 결과라고 하자.
    2. 만약 edgeslast 보다 많은 요소를 가진다면 true , 아니면 false
  2. after 가 설정되어 있으면
    1. 서버가 효율적으로 after 이전에 요소가 존재하는지 결정 할 수 있다면 true 를 반환한다.
  3. false 를 리턴한다.

hasNextPage 는 클라이언트 인자에 의해 정의된 집합(조건에 부합하는 edges) 이후에 더 많은 edge가 있는지 여부를 리턴. first/after 를 쓸 때, 서버에서 추가 edges 가 존재하면 true 아니면 false 를 리턴한다. last/before 를 쓸 때, 서버에서 before 기준으로 추가 edges 가 존재하고, 효율적으로 수행할 수 있으면 true 아니면 false 를 리턴한다.

좀 더 공식적으로는

HasNextPage(allEdges, before, after, first, last) :

  1. first 가 설정되어 있으면
    1. edgesApplyCursorsToEdges(allEdges, before, after) 의 결과라고 하자.
    2. 만약 edgesfirst 보다 많은 요소를 가진다면 true , 아니면 false
  2. before 가 설정되어 있으면
    1. 서버가 효율적으로 before 이전에 요소가 존재하는지 결정 할 수 있다면 true 를 반환한다.
  3. false 를 리턴한다.

만약 first , last 가 동시에 있으면 위 법칙에 따라 리턴할 것이다. 하지만 페이지처리방식이 명확해 보이지 않을 것이다. 그러므로 동시에 쓰는 것은 권장하지 않는다.

startCursorendCursoredgesfirst , 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"
	    }
	  }
	}
      ]
    }
  }
}

Date: 2022-01-30 Sun 00:00

Author: Younghwan Nam

Created: 2022-11-15 Tue 08:10

Emacs 27.2 (Org mode 9.4.4)

Validate