본문 바로가기

# GraphQL/GraphQL.js

타입스크립트 GraphQL Cursor Based Pagination



커서 기반 페이지네이션


커서 기반 페이지네이션은

각 엔티티에 고유한 커서값을 할당하고,

기준 커서값사이즈로 다음 페이지를 가져오는 방식입니다.


오프셋 기반 페이징은 중간에 새로운 데이터가 삽입되면,

다음 페이지를 조회했을 때, 이전에 봤던 데이터가 포함되어 있을 수 있습니다.

몇 번째 페이지에서 몇 건 방식으로 가져오기 때문입니다.


하지만 커서 기반 페이지네이션은 그 데이터 다음에서 몇 건 방식으로 가져오기 때문에,

항상 새로운 데이터만 가져옴을 확신할 수 있습니다.




시나리오


유명한 게임인 League of Legned의 캐릭터 이름이 

오름차순으로 정렬되어 배열로 주어집니다.


어떤 캐릭터 이후의 몇 명을 한페이지로 가져오고,

이것을 다음 검색에도 활용하기 위해,

다음 첫번째 캐릭터를 추가적으로 반환해야합니다.


시작 캐릭터가 주어지지 않은 경우에는,

첫 캐릭터를 기준점으로 합니다.



데이터는 다음 형식으로 주어집니다.

export let data = new Array<Profile>();
data.push(new Profile(0, "가렌"));
data.push(new Profile(1, "갈리오"));
data.push(new Profile(2, "갱플랭크"));
data.push(new Profile(3, "그라가스"));
data.push(new Profile(4, "그레이브즈"));
data.push(new Profile(5, "나르"));
data.push(new Profile(6, "나미"));
data.push(new Profile(7, "나서스"));
data.push(new Profile(8, "노틸러스"));
data.push(new Profile(9, "녹턴"));
...




노드 설계


페이지네이션을 구성하려면 설계단계부터 준비가 필요합니다.


먼저 엔티티 오브젝트 타입부터 만들어야 합니다.

여기서는 간단하게 번호와 이름을 갖는 객체인 Profile을 정의하겠습니다.


Class : 

export class Profile {
    public id: number;
    public name: string;

    constructor(id: number, name: string) {
        this.id = id;
        this.name = name;
    }

    public base64(): string {
        return Buffer.from(this.id.toString(), "binary").toString("base64");
    }
}


GraphQL Object Type :

let profile = new GraphQLObjectType({
    name: "profile",
    fields: {
        id: {
            type: GraphQLInt,
            resolve: (parent: Profile, args) => {
                return parent.id;
            }
        },
        name: {
            type: GraphQLString,
            resolve: (parent: Profile, args) => {
                return parent.name;
            }
        }
    }
});

커서값은 번호id를 base64로 인코딩한 값을 사용하는데,

base64로 인코딩하는 이유는 다음과 같습니다.

  • 커서의 형태를 클라이언트가 예측할 수 없게 만들 수 있습니다.
  • 클라이언트가 응답에서 얻은 커서가 확실하다는 믿음을 줍니다.


이것이 무슨 뜻이나면, Key가 그라가스인 것 보다는

base64로 인코딩한 6re465286rCA7Iqk 쪽이 좀더 안전하다는 느낌을 준다고 말하는 것입니다.




커서값은 어디에?


커서값은 일반적으로 노드의 속성이 아닙니다.

즉, 노드와 커서값을 이어주는 엣지edge타입을 정의할 필요가 있습니다.


보통 엣지는 데이터 자체와, 

데이터가 언제 생겼는지에 대해 관심을 갖습니다.

예를 들면, 아래 정보들이 엣지에 포함될 수 있습니다.

  • 캐릭터 정보
  • 커서값
  • 언제 그 엣지가 생성되었는지  (= 언제 그 캐릭터가 출시되었는지)
  • ...


여기서는 단순하게 캐릭터 정보와 커서값만 가지도록 하겠습니다.

let profileEdge = new GraphQLObjectType({
    name: "profileEdge",
    fields: {
        node: {
            type: profile,
            resolve: (parent: Profile) => {
                return parent;
            }
        },
        cursor: {
            type: GraphQLString,
            resolve: (parent: Profile) => {
                return parent.base64();
            }
        }
    }
});




페이지 정보


커서 기반 페이지네이션은 어떤 커서로부터 몇 건 방식이며,

이렇게 가져온 데이터들의 묶음을 페이지page라고 합니다.


현재 페이지는 가져온 데이터들의 묶음으로 알 수 있을테니,

다음 페이지에 관한 정보들이 필요하지 않을까요?

  • 다음 페이지의 첫 번째 커서값 (없다면 null)
  • 다음 페이지가 있는지 여부


let profilePageInfo = new GraphQLObjectType({
    name: "profilePageInfo",
    fields: {
        nextStart: {
            type: GraphQLString,
            resolve: (parent: Profile[]) => {
                let startEntityOfNext = data[parent[parent.length - 1].id + 1];
                if (startEntityOfNext == undefined) return null;
                else return startEntityOfNext.base64();
            }
        },
        hasNextPage: {
            type: GraphQLBoolean,
            resolve: (parent: Profile[]) => {
                // 사용자가 가져간 데이터 중 마지막 데이터의 커서값.
                let endCursorOfCurrent = parent[parent.length - 1].base64();

                // 전체 데이터의 마지막 커서값.
                let endCursorOfConnection = data[data.length - 1].base64();

                // 둘이 같으면 다음 페이지가 없는 것.
                return endCursorOfCurrent != endCursorOfConnection;
            }
        }
    }
});




커넥션 정보


커넥션connection이란 현재 요청하고 있는 그 상황 자체를 말합니다.

커넥션에 대한 정보란 아래와 같은 것들이 있습니다.

  • 전체 데이터 건수
  • 가져온 엣지 정보 <- profileEdge[]
  • 다음 페이지의 존재유무 <- profilePageInfo


전체 데이터 건수를 제외한 아래의 정보들은,

profileEdge나 profilePageInfo를 가르킵니다.

let profileConnection = new GraphQLObjectType({
    name: "profileConnection",
    fields: {
        totalCount: {
            type: GraphQLInt,
            resolve: () => {
                return data.length;
            }
        },

        edges: {
            type: new GraphQLList(profileEdge),
            resolve: parent => {
                let start = 0;

                if (parent.after != undefined) {
                    start = -1;
                    for (let i = 0; i < data.length; i++) {
                        if (data[i].base64() === parent.after) {
                            start = i;
                            break;
                        }
                    }
                    if (start == -1) {
                        throw Error(`invaild cursor : ${parent.after}`);
                    }
                }

                return data.slice(start, start + parent.first);
            }
        },

        pageInfo: {
            type: profilePageInfo,
            resolve: parent => {
                let start = 0;

                if (parent.after != undefined) {
                    start = -1;
                    for (let i = 0; i < data.length; i++) {
                        if (data[i].base64() === parent.after) {
                            start = i;
                            break;
                        }
                    }
                    if (start == -1) {
                        throw Error(`invaild cursor : ${parent.after}`);
                    }
                }

                return data.slice(start, start + parent.first);
            }
        }
    }
});





쿼리 작성


기준 커서값과 데이터 건수를 인자로 받아

커넥션을 생성하는 쿼리를 작성합니다.

let query = new GraphQLObjectType({
    name: "paginagionQuery",
    fields: {
        profileConnection: {
            type: profileConnection,
            args: {
                first: { type: new GraphQLNonNull(GraphQLInt) },
                after: { type: GraphQLString }
            },
            resolve: (parent, args) => {
                return args;
            }
        }
    }
});




쿼리 테스트


paging 1 :

커서값을 주지 않은 상태에서, 5건을 추출합니다.



paging 2 :

hasNextPage가 true이므로,

다음 커서값인 "NQ=="를 사용하여 2건을 추출합니다.



paging 3 :

hasNextPage가 true이므로,

다음 커서값인 "Nw=="를 사용하여 999건을 추출합니다.



paging 4 :

hasNextPage가 false이므로, 탐색을 종료합니다.




참고 링크


전체 소스코드는 아래에서 얻을 수 있습니다.

https://github.com/MyAeroCode/ts-serverless-study/tree/master/src/chapter-03-expressGraphql/010-cursorPagination



참고한 아티클의 주소입니다.

https://medium.com/@mattmazzola/graphql-pagination-implementation-8604f77fb254