• GraphQL
  • SQL

GraphQL Relay Connection SQL로 구현하기

Relay Cursor Connection 페이지네이션의 SQL 구현 가이드

2019. 10. 11

릴레이 커서 커넥션(Relay Cursor Connection)은 GraphQL에서 목록을 페이지네이션하는 일반적인 방법입니다. 페이지로 나뉜 엣지 목록과 페이지네이션 정보로 구성되며 공식 명세 문서에 상세하게 정의되어 있습니다.

개념

{
  user {
    id
    name
    friends(first: 10, after: "opaqueCursor") { # 커넥션 필드
      edges { # 엣지 목록
        cursor
        node { # 향하는 노드
          id
          name
        }
      }
      pageInfo { # 페이지네이션 정보
        hasNextPage
      }
    }
  }
}

여기에서 커넥션은 목록, 엣지는 그 목록의 각 항목으로 보면 됩니다.

목록의 항목 이름을 엣지(edge)라고 부르는 것은 GraphQL이 어플리케이션 상태를 그래프(graph) 관점에서 보기 때문입니다. 위의 예시에서 유저는 여러 명의 친구를 가질 수 있습니다. 즉, 어떤 유저 노드는 다른 유저들과 친구라는 관계로 연결되어 있습니다.

           connection

                  edge +--------+
                +----->+ User B |
                |      +--------+
                |
+--------+      |
| User A +------+ edge +--------+
+--------+      +----->+ User C |
                |      +--------+
                |
                |
                | edge +--------+
                +----->+ User D |
                |      +--------+
                +
                +
                +

이런 관계를 커넥션(connection)이라 부릅니다. 그리고 이 유저의 친구 관계를 구성하는 각 연결이 바로 엣지(edge)입니다. 친구가 없는 유저는 친구 커넥션(friends)의 엣지 갯수가 0입니다😭. 반면 친구가 아주 많은 유저의 친구 커넥션 엣지 갯수는 한 번의 쿼리로 가져오기 어려울 정도로 많을 수 있습니다.

엣지가 너무 많아서 한 번에 가져오기 어려울 때에는, 엣지들을 잘 정렬해두고 순서대로 페이지네이션하는 식으로 데이터를 가져오면 됩니다. 그렇게 하기 위해서 필요한 것이 커서입니다.

“방금 거 다음 10개 더 보내줘” 같이 서버에 요청할 수 있으려면, 전체 목록에서의 엣지 위치를 유일하게 식별하는 커서가 있어야 합니다. 모든 엣지는 노드를 향하기 때문에 노드 아이디는 쉽게 커서로 쓰일 수 있습니다.

전향 페이지네이션(forward pagination)

아래는 최근 작성된 순으로 정렬한 글(post)의 목록입니다.

id(pk)    created
-------   ----------
post-20   2019-09-06
post-19   2019-09-05
post-18   2019-09-04
post-17   2019-09-03
post-16   2019-09-02
post-15   2019-09-01
post-14   2019-08-31
post-13   2019-08-30
...

이 목록에서 엣지 위치를 유일하게 식별하는 문자열은 무엇일까요? id를 고르면 될 것 같습니다. 데이터베이스의 기본키이기 때문에 유일하게 엣지를 식별할 수 있습니다.

아이디가 ‘post-19’인 글 뒤에 있는 5개의 엣지를 선택해봅시다. 많이 단순화했지만 GraphQL 쿼리에서는 아래처럼 표현됩니다.

{
  posts(first: 5, after: "post-19")
}

이 쿼리를 SQL 관점으로 바꾸겠습니다.

id        created
-------   ----------
post-20   2019-09-06
post-19   2019-09-05 <- pointed by `after`
post-18   2019-09-04 <- 1
post-17   2019-09-03 <- 2
post-16   2019-09-02 <- 3
post-15   2019-09-01 <- 4
post-14   2019-08-31 <- 5
post-13   2019-08-30
...

after로 주어진 id가 가리키는 행을 찾고, 만약 어떤 행에 매치했으면 그 행을 제외한 다음 5개의 행을 선택합니다.

실제 SQL 쿼리는 아래처럼 됩니다.

SELECT id FROM post
WHERE created < (SELECT created FROM post WHERE id = 'post-19')
ORDER BY created DESC
LIMIT 5

이렇게 구현한 페이지네이션을 클라이언트에서는 어떻게 사용할까요?

query {
  # 첫 번째 페이지 쿼리
  posts(first: 5) {
    cursor # post-20, post-19, post-18, post-17, post-16 순으로 반환
    node {
      id
      # ...
    }
  }
}

이제 첫 번째 쿼리에서 커서를 반환해주었으므로 다음 페이지도 쿼리 가능합니다.

query {
  # 첫 번째 페이지 쿼리
  posts(first: 5, after: "post-16") { # 첫 쿼리가 반환한 마지막 커서를 여기에 사용합니다.
    cursor # post-15, post-14, post-13, post-12, post-11
    node {
      id
      # ...
    }
  }
}

후향 페이지네이션(backward pagination)

후향 페이지네이션의 경우는 어떨까요? 여기에서 후향 페이지네이션이란, 어떤 목록의 뒤로부터 항목을 가져오는 페이지네이션입니다.

GraphQL로는 아래처럼 표현합니다.

{
  posts(last: 5) # before 없음 == 맨 뒤부터 시작
  posts(last: 5, before: "post-4")
}

뒤에서부터 가져와도 커넥션이 대상으로 하는 전체 리스트의 정렬 순서는 바뀌지 않아야 한다는 점에 유의해야 합니다.

위의 쿼리문 두번째 예시인 posts(last: 5, before: "post-4") 필드를 실행해봅시다. 방금 전과는 반대로 정렬된 리스트에서 시작합니다.

id        created
-------   ----------
post-1    2019-08-03
post-2    2019-08-04
post-3    2019-08-05
post-4    2019-08-06 <- pointed by `before`
post-5    2019-08-07 <- 1
post-6    2019-08-08 <- 2
post-7    2019-08-09 <- 3
post-8    2019-08-10
...

SQL은 아래처럼 작성하게 됩니다.

WITH __backward_edges__ AS (
  SELECT id FROM post
  WHERE created > (SELECT created FROM post WHERE id = 'post-4')
  ORDER BY created ASC
  LIMIT 3
)
SELECT * FROM __backward_edges__
ORDER BY created DESC

여전히 최신순으로 배열되어야 하므로 쿼리에서는 정렬 순서를 다시 뒤집었습니다.

커서 기반 페이지네이션

Relay Cursor Connection은 커서 기반 페이지네이션을 정의하고 있습니다.

커서 기반 페이지네이션은 오프셋 기반 페이지네이션과 비교할 때 몇가지 장점이 있습니다.

  • 데이터베이스 인덱스에서 OFFSET n 작업을 수행하는 것은 n이 증가함에 따라 느려집니다. 커서 기반 페이지네이션은 일정한 시간복잡도로 항목을 쿼리할 수 있습니다.
  • 페이지 외부에 항목이 append 또는 prepend 되더라도 페이지 내 항목 선택이 바뀌지 않습니다.

React Relay 클라이언트에서는 커넥션을 간단히 페이지네이션할 수 있도록 PaginationContainer를 제공합니다. 서버가 명세를 잘 만족한다면 클라이언트에서는 편리하게 구현할 수 있습니다.

https://relay.dev/docs/en/pagination-container

그 외