[NestJS] Cursor-Based-Pagination에 다가가기 #1 (feat. 커서 기반 페이징의 특징과 Nest에서 구현해보기)

DatQueue·2023년 2월 28일
7

NestJS _TIL

목록 보기
9/12
post-thumbnail

시작하기에 앞서

지난번에 "Typeorm을 통해 nest에서 페이지네이션을 어떻게 구현하는가"에 관해 글을 작성해보았다. (해당 글 아래 링크 참조)


Pagination with offset-based (벨로그 포스팅)


위 글에서 소개한 페이지네이션은 엄밀히 말하자면 "Offset(오프셋)" 기반의 페이지네이션이다.

간단히 해당 부분의 코드만 살펴보자면

const [users, total] = await this.userRepository.findAndCount({
   take: pageOptionsDto.take,
   skip: pageOptionsDto.skip, 
});

위와 같이 가져올 "maxlimit"에 해당되는 take와 "offset"에 해당하는 skip 값을 통해, 클라이언트의 쿼리 요청에 따라 데이터를 넘겨주게 된다.

흔히 우리가 보게 되는 아래와 같은 사례가 "offset" 기반의 페이지네이션이다.

이렇게 오프셋 기반의 페이지네이션을 이용하면 프론트 단에서 구현한 아래와 같은 네비게이션 UI를 통해 유저가 쉽게 원하는 위치의 글 (혹은 데이터)을 조회할 수 있다. 구글 또한 검색에 대한 조회에 있어서 해당 "오프셋 기반 페이지네이션"을 사용하고 있다.

위의 언급한 장점들로 보아 오프셋 기반의 페이지네이션이 만능일까?

그렇진 않다. 구글도 <통합 검색> 조회의 페이지네이션에는 위와 같이 오프셋 기반의 페이지네이션을 적용하고 있지만, "이미지 조회"와 같은 경우엔 "무한 스크롤(Infinite Scrolling)"을 통해 보여주고 있다.

유튜브 또한 영상을 조회하는데 있어 "무한 스크롤"을 통해 보여주고, 개발자들이 자주 사용하는 슬랙(Slack)또한 글을 조회하는데 있어 이와 같은 방법을 사용한다.

그리고 이런 "무한 스크롤(Infinite Scrolling)" 방식은 "커서 기반(Cursor-based)"의 페이지네이션을 통해 구현하게 된다.

또한 우리가 "무한 스크롤"과 더불어 흔히 보게 되는 "더 보기 (+more)" 방식 또한 커서 기반의 페이지네이션이라 할 수 있다.

유저는 이러한 커서 기반의 페이지네이션을 통해서 조금 더 UI/UX 적으로 편하게 데이터에 접근 및 조회를 할 수 있다. 실제 조사에 따르면, 유저들이 오프셋 기반 페이지네이션에서 데이터를 조회할 경우, 상위 노출 몇 페이지를 제외하곤 거의 조회하지 않는다는 통계가 나왔다.

사실, 위와 같은 UI및 UX 적인 요소는 프론트단에서 구현할 문제이다.
이번 포스팅에선 "Backend"와 "DB"적으로 해당 페이지네이션의 사용 이유및 구현 방법에 접근을 해보고자 한다.

커서 기반의 페이지네이션이 오프셋 기반의 페이지네이션에 비해 성능 적으로 어떠한 차이 및 특징을 가지고, 또 nest에선 typeorm을 이용해 해당 기술을 어떻게 구현하고, 클라이언트에겐 어떠한 데이터를 넘겨줘야 할지에 관해 알아볼 것이다.


💥 Query 이슈에 따른 Offset vs Cursor 비교


> 페이징 시 속도 (INDEX의 관점)

처음 비교할 부분은 두 방식의 "성능 (그 중에서도 페이징 속도)"에 대하여다.

cursor 기반과 offset기반 페이지네이션의 성능 차이는 몇 건 되지 않는 데이터에 대해선 큰 차이가 없다. 하지만 데이터 수가 몇 만건, 몇 십만건 과 같이 늘어날 수록 (테이블의 row수가 늘어날 수록) 확연한 차이를 불러일으킨다.

1만 건의 데이터가 있다고 하자. 또한, 한 페이지당 불러오는 데이터(limit)를 "10"건이라 가정하자. 만약 마지막 데이터 "10"개를 불러오고자 할 때, 두 페이징 기법(offset, cursor based)은 이를 어떻게 처리하게 될까?

offset-based

오프셋 기반 방식이나, 커서 기반 방식 모두 마지막 데이터 10개를 던져주는 것은 동일하지만, "데이터를 읽는 방식"에 차이가 있다.

오프셋 기반으로 1만건의 데이터 중 마지막 10개 데이터를 얻고자 한다면 (다시 말해서 9991~10000건 데이터) "9990 + 10"개의 데이터를 전부 읽게 된다.

cursor-based

반면에 커서 기반 방식은 클라이언트에게 응답해 준 마지막 데이터 (cursor 값) 다음의 값만 읽은 후 던져준다. 즉, 1만건의 데이터건, 10만건의 데이터건, 이전의 데이터를 모두 읽고 원하는 데이터를 던져주는 것이 아닌, 요청 받은 해당 데이터만 읽어주면 되는 것이다.

이것은 분명한 성능의 이점이다. 대용량 데이터를 처리하는데 있어서는 이러한 성능 이슈가 분명 중요한 키가 될 수 있다.


간단한 쿼리 문을 통한 검증

정말, 위의 성능 이슈가 발생하는지 직접 생성한 4만건의 더미 데이터를 통해 검증해보았다. 아주 간단한 쿼리문을 통해 확인해보자.

products 테이블의 Pk로 설정된 id 값의 역순 정렬을 통해 마지막 10개의 데이터 (10 ~ 1)를 불러오도록 한다.
(역순 정렬은 생성된 시간에 대한 컬럼을 생성하기 귀찮아 임의로 최신 정렬을 위해 구현하였습니다... ㅎㅎㅎ)

먼저 오프셋 방식이다.

-- offset
select *  from admin.products
order by products.id desc
limit 10
offset 39989;

mysql의 프로파일링을 통해 퍼포먼스의 성능을 확인해보자.

SELECT @@profiling;
SET profiling=1;
SHOW profiles;

실행되는 데 걸리는 duration을 확인할 수 있었다. 오프셋을 통한 쿼리의 duration0.04632725sec가 도출되었다.


다음은 커서 방식이다.

-- cursor
select * from admin.products
where id < 11
order by products.id desc
limit 10;

잠깐 쿼리문을 통해 커서 기반을 간단히 설명하자면, 페이지당 select 하는 maxlimit의 수는 동일하게 적용되지만 오프셋은 offset을 사용하는 반면, 커서 기반은 where 쿼리 문을 통해 조회하는 영역을 나타낸다.
where id < 11에서 11은 이전 페이지 마지막 데이터이자 설정해 준 커서값이라 보면 된다.

동일한 방법으로 실행되는데 소요되는 시간을 구해보았을 때, 커서를 통한 쿼리의 duration0.00088400sec로 도출되었다.

아직 대용량 데이터 처리의 경험이 없는 나로써는 해당 소요 시간이 서비스에 어떠한 영향을 끼치는지 체감은 가지않지만 "커서 기반"을 통한 소요 시간이 "오프셋 기반"에 비해 확연히 적은 것을 알 수 있었다. 프론트 단에서 어떻게 해당 데이터를 렌더링할지는 모르지만, 렌더링 과정에서 분명한 성능 이슈가 있을것은 틀림없어 보인다.


SQL INDEX의 관점에서 본 성능 차이

물론, 편하게 생각해서 두 성능 차이의 이유를 "이전의 데이터를 다 가져오는 offsetwhere절을 통한 앞전 데이터와의 비교" 정도로 생각할 수도 있다.

하지만, 위는 어디까지나 직관적인 생각이고 정확히는 INDEX의 적용에 따라 성능의 차이가 생기게 된다.

( 인덱스에 대한 글은 아니므로 아주 간단히 필요한 핵심 위주로 알아보자. )

인덱스는 데이터베이스에 꼭 필수적인 개념은 아니다. 인덱스가 없더라도 어느 정도의 데이터 조회 및 변경을 하는 작업에 있어 큰 문제가 되지 않는다.

그럼에도 인덱스 자체는 데이터베이스를 이용하는데있어 성능 측면에서 아주 중요한 역할을 한다.

단순 MySQL 스키마 설계를 넘어서 좀더 효율적인 설계를 하고 싶다면 INDEX(인덱스)를 꼭 알아야 할 필요가 있다.

인덱스는 알다시피 책으로 비유하자면 "색인"과 같은 역할을 한다. 예를 들어 데이터베이스에 대해 아얘 무지한 초심자가 db관련 책에서 INDEX란 단어를 찾는다고 하자. 만약, 책 머릿말 부근에 "목록" 혹은 가장 뒷면에 "색인"이 존재하지 않는다면 책 전체를 일일이 넘겨가며 찾아야 할 것이다. 하지만 책에 해당 요소가 존재한다면 빠른 시간내에 원하는 부분을 찾을 수 있을 것이다.

"Cursor-based(커서 기반)" 페이지네이션도 이와 동일한 원리이다. 인덱스의 비교를 통해서 우린 빠르게 요청한 데이터를 읽어오고 던져줄 수 있는 것이다.


그렇다면 우리가 앞전 시행한 쿼리문에서 인덱스가 정말로 매겨져 있는 것일까?


사실 위의 문장이 중요하다. 위의 질문에 대해 알기 위해 데이터베이스의 원리에 관해 조금 더 들어가자면 인덱스는 크게 "Clustered Index(클러스터형 인덱스)""Non-Clustered Index(보조 인덱스)"로 나뉜다.

영어사전과 같이 이미 알파벳 순으로 정렬된 케이스가 "클러스터형 인덱스", 책 뒤에 색인이 있어 해당 색인을 보고 원하는 값을 찾는 일반적 책과 같은 케이스가 "보조 인덱스"라고 생각하면 편하다.

클러스터형 인덱스는 행 데이터를 인덱스로 지정한 열에 맞춰 자동 정렬한다. 쿼리 문에서 직접 지정해주진 않았지만 일전 Typeorm을 통해 해당 products 테이블의 엔터티를 구성하는 단계에서 우리는 id 컬럼을 PRIMARY KEY로 지정해 주었다.

참고

import { User } from "../../user/model/user.entity";
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";

@Entity('products')
export class Product {
  
  @PrimaryGeneratedColumn()  // --> PRIMARY KEY로 지정해줌에 따라 클러스터형 인덱스 자동 생성
  id: number;

  @Column()
  title: string;
  
  @Column()
  description: string;

  @Column()
  image: string;

  @Column()
  price: number;

  @ManyToOne(() => User, user => user.products)
  user: User 
}

즉, id 컬럼은 사실 order by를 통한 정렬을 해주지 않더라도 자동 정렬이 된다.

결론은, 우리가 실행한 간단한 커서를 활용한 쿼리문에서

-- cursor
select * from admin.products
where id < 11
order by products.price desc
limit 10;

해당 id 컬럼은 클러스터형 인덱스를 타고 있는 것이다.


여기서 의문이 들었다

"그렇다면 인덱스를 타지 않는 커서 기반 페이지네이션은 확실한 성능 차이를 발생시키지 못하는 것일까?"

커서값으로 사용하게 되는 컬럼이 인덱스를 타지 않는 경우라면 어떨까에 대한 의문이다.

만들어준 위의 테이블에서 price와 같은 경우는 인덱스를 타고 있지 않다. 그 경우 실제로 price를 값의 크기에 따라 역순 정렬 후 마지막 10개의 데이터의 값을 비교했더니 거의 차이가 없는 것을 확인할 수 있었다.

(동일한 price 값이 많은 관계로 정확한 커서 위치는 파악할 수 없어 정확한 비교는 하지 않았습니다.)

인덱스를 타는 컬럼을 통해 성능의 차이를 비교했을 땐 확연한 차이가 났지만, 그렇지 않았을 경우는 거의 (약 1.x 배 가량) 차이가 나지 않았다.

이를 통해 인덱스를 타지 않는 이상 Cursor-based 페이지네이션은 속도의 관점에선 의미가 없다고 파악하였다.


데이터 중복및 누락 발생 이슈

offset-based와 cursor-based의 비교(사실 offset의 단점을 알려주기 위한?)에서 가장 많이 나오는 이슈이다.

최신 데이터를 기준으로 페이지 매김을 하는 경우를 가정하자.

"오프셋 기반"에서 만약 사용자가 첫 페이지를 조회하고 있던 도중, 추가로 하나의 데이터가 추가된다고 하자. 그 후, 사용자가 두 번째 페이지로 넘어가 조회를 하게 되는 경우 첫 번째 페이지의 마지막 데이터가 두 번째 페이지에서 중복되어 노출된다. 그냥 넘어갈 수도 있지만 UX측면에서 보면 좋지 못하다.

간단한 유저 데이터로써 검증을 해보자.

(유저 데이터를 생성하고 서비스단에서 처리하는 과정에 대한 설명은 생략하겠습니다. -- 주제에서 벗어남 ㅎㅎ)


테이블을 구성한 엔터티및 서비스 로직만 알아보자.

※ User Entity

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Exclude({toPlainOnly: true})
  @Column()
  first_name: string;

  @Exclude({toPlainOnly: true})
  @Column()
  last_name: string;

  @Column({nullable: true})
  @Expose({toPlainOnly: true})
  get fullName(): string {
    return `${this.first_name} ${this.last_name}`;
  }

  set fullName(fullName) {}

  @Column({unique: true})
  email: string;

  @Column()
  @Exclude({toPlainOnly: true})  
  password: string;
}

※ User Service

async paginate(pageOptionsDto: PageOptionsDto): Promise<PageDto<User>> {

    const [users, total] = await this.userRepository.findAndCount({
      take: pageOptionsDto.take,
      skip: pageOptionsDto.skip, 
      order: {
        id: pageOptionsDto.sort.toUpperCase() as any,
      },
    });

    const pageMetaDto = new PageMetaDto({pageOptionsDto, total});
    const last_page = pageMetaDto.last_page;

    if (last_page >= pageMetaDto.page) {
      return new PageDto(users, pageMetaDto);
    } else {
      throw new NotFoundException('해당 페이지는 존재하지 않습니다');
    }
  }

쿼리 요청

http://localhost:5000/api/users?sort=desc&take=3

정렬을 나타내는 sort는 PRIMARY-KEY인 id를 기준으로 하였고 3개의 데이터를 페이지당 조회할 limit으로한다.

전체 데이터는 총 13개를 생성한 상태이다.


첫 번째 페이지 응답 확인

{
    "data": [
        {
            "id": 16,
            "email": "n@n.com",
            "fullName": "n n"
        },
        {
            "id": 15,
            "email": "m@m.com",
            "fullName": "m m"
        },
        {
            "id": 14,
            "email": "l@l.com",
            "fullName": "l l"
        }
    ],
    "meta": {
        "page": 1,
        "take": 3,
        "total": 13,
        "last_page": 5,
        "hasPreviousPage": false,
        "hasNextPage": true
    }
}

이 상태에서 추가로 새 유저 데이터를 하나 생성해 보자.

{
    "first_name": "o",
    "last_name": "o",
    "email": "o@o.com"
}

두 번째 페이지 응답 확인

그 후, 바로 오프셋 방식에서 동일한 조건하에 두 번째 페이지를 요청해보자.

http://localhost:5000/api/users?sort=desc&take=3&page=2
{
    "data": [
        {
            "id": 14,
            "email": "l@l.com",
            "fullName": "l l"
        },
        {
            "id": 13,
            "email": "k@k.com",
            "fullName": "k k"
        },
        {
            "id": 12,
            "email": "j@j.com",
            "fullName": "j j"
        }
    ],
    "meta": {
        "page": 2,
        "take": 3,
        "total": 14,
        "last_page": 5,
        "hasPreviousPage": true,
        "hasNextPage": true
    }
}

첫 번째 페이지의 마지막 데이터인 "id": 14인 데이터가 한번 더 중복해서 응답되는 것을 확인할 수 있다.

위와 같은 현상은 데이터 삭제 시에도 "데이터 누락"으로써 야기된다.
(구현은 생략하도록 하겠다.)

하지만, INDEX를 통한 커서 기반의 페이지네이션은 말 그대로 인덱스를 타며 조회되기 때문에 위와 같은 이슈가 일어나지 않는다.


> offset-based vs cursor-based 비교에 따른 생각

페이지네이션을 하는데 있어 다양한 블로그 (혹은 글)를 보면 "offset 사용하지 마!!" 라는 것에 중점을 둔 글이 많다.

물론 위에서 알아본 것과 같은 이슈를 해결하기엔 오프셋 방식보다 커서 기반의 방식이 우수한 것은 맞지만, 이게 전부는 아니다. 성능 측면에서 조금 더 들어가자면 INDEX를 통해 커서값을 비교해 페이지네이션을 하는 것 자체는 쿼리의 성능을 향상 시키지만 WHERE 절의 구문이 복잡해질 수록, 혹은 서브쿼리가 많아질수록, 쿼리의 부하는 자연스럽게 생기기 마련이다.
흔히, 부르는 trade-off 현상인 것이다.
또한, INDEX를 통한 방식은 SELECT(조회)에선 확실한 성능을 보장하지만 SELECT가 아닌 데이터의 변경 작업(INSERT, UPDATE , DELETE)이 자주 일어나면 오히려 성능을 떨어뜨릴 수 있다.

만들고자 하는 서비스에서 (게시판, 쇼핑몰 조회 등등) 구현하고자 하는 컨텐츠에 따른 유저의 경험에 기반해 적절한 방식을 채택하는 것이 가장 좋지 않나 생각을 해본다.


💥 NestJS | Typeorm을 통한 Cursor-based pagination 구현


node 기반으로 된 cursor 기반 페이지네이션 자료를 찾던 도중 typeorm-cursor-pagination 이란 라이브러리가 존재하는 것을 확인하였다.
npm_ typeorm-cursor-pagination

하지만 공부를 하고 처음 해당 개념을 접하는 입장에서 라이브러리를 사용하여 구현하는 것은 도움이 되지 않을거라 생각하고 직접 구현해보기로 하였다. 노드기반으로 된 글은 단 하나도 찾지 못하였지만 최대한 원리에 기반하여 간단히 구현해보기러 하였다.

> 구상 및 모델 생성

오프셋 기반의 페이지네이션을 구현하였을때와 틀은 동일하다.


※ 참고
[링크] Pagination with offset (Typeorm) _벨로그


하지만, 크게 보면 클라이언트에서 요청할 쿼리에 작성해야할 옵션과 응답시 클라이언트에 던져줄 메타 정보는 다를 것이다.

Query-parameter-options

// offset-page.options.ts

import { Type } from "class-transformer";
import { IsEnum, IsInt, IsOptional } from "class-validator";
import { Order } from "./page-order.enum";

export class PageOptionsDto {

  @Type(() => String)
  @IsEnum(Order)
  @IsOptional()
  readonly sort?: Order;

  //@Type(() => String)
  //@IsOptional()
  //readonly s?: string = ''; 
  
  @Type(() => Number)
  @IsInt()
  @IsOptional()
  page?: number = 1;

  @Type(() => Number)
  @IsInt()
  @IsOptional()
  readonly take?: number = 9; 
  
  get skip(): number {
    return this.page <=0 ? this.page = 0 : (this.page - 1) * this.take;
  }
}

위는 offset 기반의 페이지네이션중 요청 쿼리로 던져줄 옵션 파라미터에 대한 모델이다. take와 같은 경우는 페이지 당 불러올 limit 값으로 이는 커서 기반의 페이지네이션에서도 동일하게 던져줄 수 있다.
핵심은 page 값과 page 값에 따라 적용되는 offset 값(여기선 skip()으로 구현)이다.

// cursor-page.options.ts

import { Type } from "class-transformer";
import { IsEnum, IsOptional } from "class-validator";
import { Order } from "./c_page-order.enum";

export class CursorPageOptionsDto {

  @Type(() => String)
  @IsEnum(Order)
  @IsOptional()
  readonly sort?: Order = Order.DESC;

  @Type(() => Number)
  @IsOptional()
  readonly take?: number = 5;

  @Type(() => String)
  @IsOptional()
  readonly cursorId?: number = "" as any;
}

위는 cursor 기반의 페이지네이션이다. take 값은 클라이언트(혹은 사용자)의 입맛에 맞게 맡길수도 있으므로 보내주기러 하고, 오프셋 방식과는 다르게 cursorId 값을 보내주기러 한다.

해당 cursorId가 바로 조회된 페이지의 마지막 데이터에 해당하는 "커서값"이다.


✔ page-meta-data

// offset-page.meta.dto.ts

import { PageMetaDtoParameters } from "./meta-dto-parameter.interface";

export class PageMetaDto {
  readonly total: number;
  readonly page: number;
  readonly take: number;
  readonly last_page: number;
  readonly hasPreviousPage: boolean;
  readonly hasNextPage: boolean;

  constructor({pageOptionsDto, total}: PageMetaDtoParameters) {
    this.page = pageOptionsDto.page <= 0 ? this.page = 1 : pageOptionsDto.page;
    this.take = pageOptionsDto.take;
    this.total = total;
    this.last_page = Math.ceil(this.total / this.take);
    this.hasPreviousPage = this.page > 1;
    this.hasNextPage = this.page < this.last_page;
  }
}

offset 기반은 page 별로 매김을 하기 때문에 현 위치의 페이지 값과 가장 마지막 페이지인 last_page를 응답해주고, 이전 페이지 혹은 다음 페이지가 존재하는지의 여부 또한 넘겨주도록 하였다.

// cursor-page.meta.dto.ts

import { CursorPageMetaDtoParameters } from "./c_meta-dto-parameter.interface";

export class CursorPageMetaDto {

  readonly total: number;
  readonly take: number;
  readonly hasNextData: boolean;
  readonly cursor: number;

  constructor({cursorPageOptionsDto, total, hasNextData, cursor}: CursorPageMetaDtoParameters) {
    this.take = cursorPageOptionsDto.take;
    this.total = total;
    this.hasNextData = hasNextData;
    this.cursor = cursor;
  }
}

cursor 기반의 경우는 페이지 인덱스에 매김이 되는 것이 아니므로 offset 기반의 경우와 넘겨줄 메타 정보에서도 차이가 있다. 현재 페이지에서 불려온 데이터 목록들이 마지막 데이터를 가지고 있다면 그것에 대한 응답을 해줘야 할 것이고, 가장 중요한 다음 데이터 조회를 위한 cursor 값을 응답해줘야 할 것이다. 해당 커서값을 토대로 쿼리 요청시 cursorId를 던지게 된다.

아래는 Slack api에서 받아온 페이지네이션 응답 json 데이터이다. 응답으로 next_cursor 값을 통해 커서값을 보내주고 있다.

(더 넘겨줄 데이터가 있을 수 있지만 일단 위와 같이 작성)


아래는 모델 구현에 사용된 나머지 객체에 대한 파일이다.

✔ cursor-page.dto.ts

import { IsArray } from "class-validator";
import { CursorPageMetaDto } from "./c_page-meta.dto";

export class CursorPageDto<T> {

  @IsArray()
  readonly data: T[];

  readonly meta: CursorPageMetaDto;

  constructor(data: T[], meta: CursorPageMetaDto) {
    this.data = data;
    this.meta = meta;
  }
}

✔ cursor-page-order.enum.ts

export enum Order {
  ASC = "asc",
  DESC = "desc"
}

✔ cursor-page-options-parameter.interface.ts

import { CursorPageOptionsDto } from "./c_page-options.dto";

export interface CursorPageMetaDtoParameters {
  cursorPageOptionsDto: CursorPageOptionsDto;
  total: number;
  hasNextData: boolean;
  cursor: number;
}

> 기능 구현 (서비스단)

import { Injectable } from '@nestjs/common';
import { CursorPageMetaDto } from 'src/user/cursor-pagination_model/c_page-meta.dto';
import { CursorPageOptionsDto } from 'src/user/cursor-pagination_model/c_page-options.dto';
import { CursorPageDto } from 'src/user/cursor-pagination_model/c_page.dto';
import { LessThan, Like } from 'typeorm';
import { Product } from './model/product.entity';
import { ProductRepository } from './repositories/product.repository';

@Injectable()
export class ProductService {
  constructor(private readonly productRepository: ProductRepository) {}

  async cursorBasedPaginated(cursorPageOptionsDto: CursorPageOptionsDto): Promise<CursorPageDto<Product>> {
    const [products, total] = await this.productRepository.findAndCount({
      take: cursorPageOptionsDto.take,
      where: cursorPageOptionsDto.cursorId ? {
        id: LessThan(cursorPageOptionsDto.cursorId),
      }: null,
      order: {
        id: cursorPageOptionsDto.sort.toUpperCase() as any,
      },
    });

    const takePerPage = cursorPageOptionsDto.take;
    const isLastPage = total <= takePerPage;
    
    let hasNextData = true;
    let cursor: number;

    if (isLastPage || products.length <= 0) {
      hasNextData = false;
      cursor = null;
    } else {
      cursor = products[products.length - 1].id;
    }

    const cursorPageMetaDto = new CursorPageMetaDto({ cursorPageOptionsDto, total, hasNextData, cursor });

    return new CursorPageDto(products, cursorPageMetaDto);
  }
}

"Typeorm"을 통해 구현하는데 있어, 더 깔끔하게 보여지고 조금 더 직관적으로 쿼리 문을 대체할 수 있는 QueryBuilder 패턴 대신, Find Operator를 사용하였다.

✔ 데이터 조회

const [products, total] = await this.productRepository.findAndCount({
   take: cursorPageOptionsDto.take,
   where: cursorPageOptionsDto.cursorId ? {
   	 id: LessThan(cursorPageOptionsDto.cursorId),
   }: null,
   order: {
     id: cursorPageOptionsDto.sort.toUpperCase() as any,
   },
});

select 및 조회된 데이터를 count하는데 있어, findAndCount() 내장 함수를 이용하였고 (takeorder은 옵션 객체에서 받아옴) cursorId 즉, 커서값에 따라 다음의 데이터를 불러오는 과정은 where 속성 내부의 LessThan() 함수를 통해서 구현하였다.

쿼리 요청시 날리게 될 cursorId 값보다 작은 id 값(인덱스를 탄 id 값)의 데이터 중 지정한 take(limit)만큼 데이터를 불러오는 것이다.

또한 간단한 삼항 연산자를 통해, 이전 데이터가 존재하지 않을 경우 (즉, 커서값 cursorId가 없을 경우) where 절을 실행시키지 않도록 하였다.


※ 참고
typeorm/find-optinos__advanced-options


세부기능 구현

const takePerPage = cursorPageOptionsDto.take;
const isLastPage = total <= takePerPage; 

let hasNextData = true;
let cursor: number;

if (isLastPage) {
  hasNextData = false;
  cursor = null;
} else {
  cursor = products[products.length - 1].id;
}
  • takePerPage : 사실 페이지? 라는 단어가 적절한 선택인 줄 모르겠지만 보여지는(클라이언트에게 던져주는) 페이지 당 가져올 데이터 갯수를 나타낸다.

  • isLastPage (혹은 hasLastData라는 변수명도 괜찮을지도 모른다.)
    : 해당 페이지가 마지막 데이터를 가지고 있는지에 대한 불리언 값이다. 만약, 무한 스크롤링에 관한 구현이라면 isLastScroll이라 생각하면 편하다. 만약 전체 데이터가 위에서 구한 takePerPage 수의 이하일 경우 마지막 페이지로 간주한다.

    즉, isLastPage는 깔끔하게 take에 의해서만 결정되게 끔 하였다.

  • hasNextData : meta 정보에 넘겨줄 값이다. default로 true를 유지하고 isLastPage일 시 false를 반환케끔 한다.

  • cursor : 쿼리 요청 (cursorId)에 쓰일 수 있게 끔 메타 정보로 넘겨 줄 커서 값이다. 다음 데이터가 존재하는 경우, 즉 isLastPage가 아닐 경우엔 현제 페이지에 불러온 데이터 중 마지막 데이터의 id값으로 받아온다.
    만약, isLastPage일 경우엔 null을 반환케끔 한다.


컨트롤러 api

  @Get('cursorPaginate')
  async cursorPaginate(
    @Query() cursorPageOptionsDto: CursorPageOptionsDto
  ): Promise<CursorPageDto<Product>> {
    return await this.productService.cursorBasedPaginated(cursorPageOptionsDto);
  }

> Postman을 통한 요청 | 응답 확인


어떠한 쿼리문도 작성하지 않았을 경우

http://localhost:5000/api/products/cursorPaginate
{
    "data": [
        {
            "id": 40000,
            "title": "rem exercitationem",
            "description": "voluptas dicta explicabo sed quidem ipsa quae consectetur corrupti architecto",
            "image": "https://loremflickr.com/640/480",
            "price": 36
        },
        {
            "id": 39999,
            "title": "illum repellat",
            "description": "sit velit necessitatibus repellat nobis aliquam rem amet illum impedit",
            "image": "https://loremflickr.com/640/480",
            "price": 10
        },
        {
            "id": 39998,
            "title": "quos non",
            "description": "eos consectetur quae facere doloribus itaque dolor voluptatum quidem suscipit",
            "image": "https://loremflickr.com/640/480",
            "price": 87
        },
        {
            "id": 39997,
            "title": "aut recusandae",
            "description": "corporis minima neque eum aspernatur provident quisquam laudantium natus dolorem",
            "image": "https://loremflickr.com/640/480",
            "price": 104
        },
        {
            "id": 39996,
            "title": "labore quo",
            "description": "sequi ratione sunt illo animi omnis vitae ipsa nostrum veritatis",
            "image": "https://loremflickr.com/640/480",
            "price": 22
        }
    ],
    "meta": {
        "take": 5,
        "total": 39999,
        "hasNextData": true,
        "cursor": 39996
    }
}

CursorPageOptionsDto모델에서 take의 디폴트값으로 5를 설정하였고, (최신 데이터 정렬을 야매?로 나태내주기 위해) Primary-Keyid의 정렬 디폴트 값을desc로 설정하였기 때문에 아무런 값을 요청하지 않아도 위와 같이 5개의 데이터와 해당 메타정보들이 응답되었다.

( 위와 같이 id 값을 DESC으로 정렬하는 것이 좋지 않다는 말도 있다. 해당 이슈는 sql의 Ascending IndexDescending Index의 개념에 근거하는데 아직 잘 모르고, 주제와 멀어져 다음에 다루도록 하겠다. )

cursor 값 또한 불러온 데이터 중 마지막 데이터의 id값임을 확인할 수 있다.


커서값을 통한 다음 페이지 이동

각 요청마다 메타 데이터로 받아온 커서값을 요청 쿼리로 보내 다음 데이터들을 얻을 수 있다.

http://localhost:5000/api/products/cursorPaginate?cursorId=39996
{
    "data": [
        {
            "id": 39995,
            "title": "tempora explicabo",
            "description": "aut odit iure eius ipsa ratione cumque rem voluptas repellendus",
            "image": "https://loremflickr.com/640/480",
            "price": 16
        },
      
        // ............
      
        {
            "id": 39991,
            "title": "quae ad",
            "description": "quos tenetur adipisci quas consectetur nostrum deserunt odit modi atque",
            "image": "https://loremflickr.com/640/480",
            "price": 30
        }
    ],
    "meta": {
        "take": 5,
        "total": 39994,
        "hasNextData": true,
        "cursor": 39991
    }
}

✔ 마지막 데이터를 담고 있는 페이지인가를 확인

현재 한 페이지당 불러오는 데이터의 개수는 디폴트 값으로 설정해준 take=5 이다. 즉, 커서값이 6일경우 마지막 데이터 리스트를 응답시킬 것이다.

http://localhost:5000/api/products/cursorPaginate?cursorId=6
{
    "data": [
        {
            "id": 5,
            "title": "repudiandae saepe",
            "description": "nostrum neque qui laboriosam dolorem facilis voluptate reiciendis quisquam repellendus",
            "image": "https://loremflickr.com/640/480",
            "price": 41
        },
        
      	// ............
      
        {
            "id": 1,
            "title": "ea explicabo",
            "description": "quo recusandae excepturi dolores quod earum facilis minus quaerat at",
            "image": "https://loremflickr.com/640/480",
            "price": 68
        }
    ],
    "meta": {
        "take": 5,
        "total": 5,
        "hasNextData": false,
        "cursor": null
    }
}

이땐 take=5 <= total=5 의 관계식이 성립되므로 해당 페이지가 마지막 페이지임을 (isLastPage) 의미한다. 즉, hasNextDatafalse를 가지게 되고, 커서값은 null을 반환케끔 하였다.

이는 가져오게 될 take의 수가 어떤지에 따라서도 동일하게 응답한다.


> 무한 스크롤(Infinite Scrolling)일 경우엔?

위의 방법도 무한 스크롤을 구현하는 방법이 될 수도 있겠지만, 해당 기능을 구현하는데 있어서 조금 더 괜찮은? 방법이 있을까 고민하였다. 물론 코드의 큰 차이는 없지만 클라이언트에게 응답해줄 데이터 정보를 다르게 취해주면 좋지 않을까 생각해보았다.

(물론 모바일에선 해당되지 않을 수도 있다. 위의 기능 구현은 "더 보기" 를 통한 페이지 이동에 가깝지 않나 싶다. 사실 이는 클라이언트단에서 고민할 문제이므로 깊게 들어가진 않도록 하자.)

위 이미지는 흔히 접하는 Youtube Shorts의 경우이다.

한 페이지에 하나의 영상을 보여주는 것 같지만 아래에 다음 영상 일부를 미세하게 보여줌으로써 유저에게 무한 스크롤임을 보여준다.

이에 따라 "무한스크롤일 경우" 클라이언트에게 요청 데이터를 응답할시 마지막 스크롤(isLastScroll)이 아닐 시엔 "현재 스크롤의 데이터(n) + 다음 스크롤의 데이터 1개", 만약 마지막 스크롤일 시엔 "현재 데이이터" 만 넘겨주기로 하였다.


서비스 로직

더 깔끔한 코드를 만들 수 있지만 일단은 구현하는 것까지만 진행하였다.

async cursorBasedScrolled(cursorPageOptionsDto: CursorPageOptionsDto): Promise<CursorPageDto<Product>> {
    const [products, total] = await this.productRepository.findAndCount({
      take: cursorPageOptionsDto.take,
      where: cursorPageOptionsDto.cursorId ? {
        id: LessThan(cursorPageOptionsDto.cursorId),
      }: null,
      order: {
        id: cursorPageOptionsDto.sort.toUpperCase() as any,
      },
    });

    let hasNextData = true;
    let cursor: number;
	let firstDataWithNextCursor: Product;

    const takePerScroll = cursorPageOptionsDto.take;
    const isLastScroll = total <= takePerScroll;
    const lastDataPerScroll = products[products.length - 1];

	//---------------------------------------------------------

    const allProducts = await this.all(); 

    if (isLastPage) {
      hasNextData = false;
      cursor = null;
    } else {
      cursor = lastDataPerScroll.id;
      firstDataWithNextCursor = allProducts[cursor - 2];

      if (cursor === firstDataWithNextCursor.id) {
        firstDataWithNextCursor = allProducts[cursor - 3];
      }

      products.push(firstDataWithNextCursor);
    }

	//------------------------------------------------------------

    const cursorPageMetaDto = new CursorPageMetaDto({ cursorPageOptionsDto, total, hasNextData, cursor });

    return new CursorPageDto(products, cursorPageMetaDto);
  }

기존의 커서 기반 방식과 큰 차이는 없다. 언급하였다시피, 넘겨줄 데이터만 변화가 있을 뿐이다.

위의 주석 처리한 안의 if ... else문을 보자.

firstDataWithNextCursor가 다음 스크롤의 첫 번째 데이터이다. 이는 "전체" 상품 데이터 allProducts 리스트의 인덱싱을 통해 받아온다.

allProducts는 서비스단의 all() 함수를 통해 불러온다.

async all(): Promise<Product[]> {
   return await this.productRepository.find();
}

firstDataWithNextCursor는 아래와 같은 식으로 구하게 된다.

firstDataWithNextCursor = allProducts[cursor - 2];

앞전에 설명하였지만 cursor는 정렬에 따른 현재 스크롤의 마지막 데이터이다. 해당 값은 allProducts[cursor-1]이므로 인덱스 숫자에 하나를 더 빼주어 그 다음 스크롤의 첫 번째 데이터를 얻어올 수 있다.

그 후,

products.push(firstDataWithNextCursor);

현재 스크롤의 데이터에 위에서 구한 firstDataWithNextCursor를 추가하여 페이지 응답시 data에 포함시켜준다.

그런데! else문 안에 넣어준 또 다른 if문의 정체는 무엇일까?

if (cursor === firstDataWithNextCursor.id) {
  firstDataWithNextCursor = allProducts[cursor - 3];
}

사실, 위는 일반적 상황에서 요구되는 코드는 아니고 단지 더미데이터 생성 시, 특정 데이터 (row)의 누락으로 인해 (이유를 모르겠습니다...😢) 해당 누락된 데이터의 앞 전 데이터들 (order bydesc로 하였을 때의 경우)의 경우에서 위의 firstDataWithNextCursor를 요구하였을때,

lastDataPerScroll === firstDataWithNextCursor 인 경우가 일어났기 때문에 임시로 응답을 위해 구현해준 코드이다. (ㅠㅠ)

(해당 문제는 해결되는데로 추가 블로깅 할 예정이다.)


✔ 문제 원인 파악 (※ 추가 블로깅 ※)

😎어 !! 그러던 와중 해결하였다!!!😎

위와 같은 문제(데이터가 중복되어 응답)를 해결하기 위해 임시로 if문을 달아 구현한건 둘째치고, 애초에 접근자체가 잘못되었다는 것을 깨달았다.

발생하였던 "문제"에 모든 것이 담겨있었다.

현재 더미데이터로 생성된 데이터가 40000 건의 데이터 인 줄 알았지만 어찌된 영문인지 PK-id=27922를 가진 데이터가 누락되어있었다. 그에 따라 데이터가 응답 시에 하나씩 밀리게 되었다.

위의 상황은 실제 데이터로 적용시킬 시, 마치 데이터 하나를 삭제했을 때 해당 rowid값이 비어버린 경우라 할 수 있다.
물론, PKid값에 Auto Increment를 통해 빈 아이디 값 없이 다시 재정렬할 수도 있겠지만 아직 해당 방법에 대해선 지식이 부족한 관계로 시행하지 않았다. (물론 데이터베이스 관점에서 더 다양한 방법이 존재하지 않을까 싶다.)

cursor = lastDataPerScroll.id;
firstDataWithNextCursor = allProducts[cursor - 2];

위는 기존의 접근법이다. cursor는 현재 스크롤의 마지막 데이터의 id 값이다. 그 후, cursor값을 통해 firstDataWithNextCursor (다음 스크롤의 첫 번째 데이터)를 구해주었다.

하지만 이것은 큰 실수였다. 앞서 언급하였듯이, 만약 id값이 "1~n"까지 1씩 커지며 정렬되는 데이터에서 특정 id값이 삭제되어서 비었다면 실제 특정 데이터의 인덱스 값-1cursor.id가 아닐 수 있다. 즉, 이러한 접근은 상당히 잘못되었음을 판단하였다. 너무 단순히 생각하였다 ㅠㅠ

✔ 수정된 코드

    const takePerPage = cursorPageOptionsDto.take;
    const isLastPage = total <= takePerPage;
    const lastDataPerPage = products[products.length - 1];

    if (isLastPage) {
      hasNextData = false;
      cursor = null;
    } else {
      cursor = lastDataPerPage.id;
      const lastDataPerPageIndexOf = allProducts.findIndex(data => data.id === cursor);
      firstDataWithNextCursor = allProducts[lastDataPerPageIndexOf - 1];

      products.push(firstDataWithNextCursor);
    }

현재 스크롤 마지막 데이터 객체의 id, 즉 cursor 자체가 해당 객체의 인덱스에 영향을 끼치는 것이 아니라, 전체 데이터(allProducts) 중 특정 데이터 객체의 id가 만약 cursor와 같을 경우 해당 Index를 찾아 위치를 구해주었다.

그 후, 해당 인덱스에서 1을 뺀값(desc 정렬이기 때문)을 다음 스크롤의 첫 번째 데이터를 구하기 위한 인덱스값으로 받아주었다.
이렇게 구함으로써 비어있는 id값에 대한 문제에 일일이 따로 대응하는 일이 없도록 하였다.


✔ 포스트맨을 통한 응답 확인 -- Infinite Scrolling

http://localhost:5000/api/products/cursorBasedScroll?take=3&cursorId=100

커서값을 100으로 하여 요청을 보내보자. take=3으로 요청을 보내 한 스크롤 당 3개의 데이터를 던지도록 한다.

{
    "data": [
        {
            "id": 99,
            "title": "rerum repudiandae",
            "description": "vel veniam voluptatibus non voluptas cupiditate modi quia labore ipsam",
            "image": "https://loremflickr.com/640/480",
            "price": 58
        },
        {
            "id": 98,
            "title": "eaque veniam",
            "description": "expedita suscipit quas ullam repellat molestiae deserunt blanditiis dolor sint",
            "image": "https://loremflickr.com/640/480",
            "price": 60
        },
        {
            "id": 97,
            "title": "molestias consequatur",
            "description": "quibusdam corrupti sed officia dicta itaque voluptatem accusamus magni a",
            "image": "https://loremflickr.com/640/480",
            "price": 37
        },
        {
            "id": 96,
            "title": "culpa autem",
            "description": "natus est voluptates iste quibusdam nam adipisci voluptatum facere voluptate",
            "image": "https://loremflickr.com/640/480",
            "price": 11
        }
    ],
    "meta": {
        "take": 3,
        "total": 99,
        "hasNextData": true,
        "cursor": 97
    }
}

cursorId=100으로 요청하였으므로 그 다음의 데이터인 id= 99, 98, 97인 데이터가 나올 것이고, 마지막 스크롤이 아니므로 id=96인 다음 스크롤의 첫 번째 데이터 하나가 추가로 응답이 된다.

하지만 meta 정보로 넘겨준 cursor값은 기존과 동일하게 97이어야 한다. 물론, 다음 스크롤 요청시엔 cursorId값으로 해당 97을 넘겨줄 것이다.

http://localhost:5000/api/products/cursorBasedScroll?take=3&cursorId=97

다음 스크롤 요청 시 응답 데이터를 확인해보자.

{
    "data": [
        {
            "id": 96,
            "title": "culpa autem",
            "description": "natus est voluptates iste quibusdam nam adipisci voluptatum facere voluptate",
            "image": "https://loremflickr.com/640/480",
            "price": 11
        },
        {
            "id": 95,
            "title": "error dolorum",
            "description": "commodi laudantium rerum dolore inventore distinctio exercitationem ab quis nihil",
            "image": "https://loremflickr.com/640/480",
            "price": 45
        },
        {
            "id": 94,
            "title": "vero ad",
            "description": "dolorum inventore nobis hic fugiat corrupti nihil doloremque a aspernatur",
            "image": "https://loremflickr.com/640/480",
            "price": 69
        },
        {
            "id": 93,
            "title": "provident aspernatur",
            "description": "quod ipsa earum delectus amet porro laudantium ipsam perferendis unde",
            "image": "https://loremflickr.com/640/480",
            "price": 79
        }
    ],
    "meta": {
        "take": 3,
        "total": 96,
        "hasNextData": true,
        "cursor": 94
    }
}

원하는대로 현재 스크롤의 첫 데이터(id=96)가 이전 스크롤에서 마지막으로 응답에 추가해준 것(id=96)과 같은 것을 확인할 수 있다.

그럼 마지막 스크롤일 경우 또한 확인해보자.

http://localhost:5000/api/products/cursorBasedScroll?take=3&cursorId=4

take=3이므로 우리의 테이블에선 1 < cursorId <= 4 일 경우 isLastScroll === true일 것이다.

{
    "data": [
        {
            "id": 3,
            "title": "nihil ipsam",
            "description": "quibusdam odio qui iusto ipsum aspernatur nisi magnam voluptatum voluptatibus",
            "image": "https://loremflickr.com/640/480",
            "price": 68
        },
        {
            "id": 2,
            "title": "quos similique",
            "description": "quisquam est in consequatur laborum consequuntur numquam doloribus occaecati dolore",
            "image": "https://loremflickr.com/640/480",
            "price": 73
        },
        {
            "id": 1,
            "title": "ea explicabo",
            "description": "quo recusandae excepturi dolores quod earum facilis minus quaerat at",
            "image": "https://loremflickr.com/640/480",
            "price": 68
        }
    ],
    "meta": {
        "take": 3,
        "total": 3,
        "hasNextData": false,
        "cursor": null
    }
}

위와 같이 isLastScroll === true 일 경우, 더 이상 응답해줄 다음 스크롤의 데이터가 없으므로 현재 데이터 값들만 응답해준다.


✔ 간단한 생각

사실, 현업에선 무한 스크롤을 어떻게 구현하는지, 혹은 정석적인 방법은 무엇인가에 대해 알지는 못하였다. (원하는 자료를 찾지 못하였다.) 위에서 구현한 방법은 엄밀히 말하자면 기존 커서-기반 페이지네이션에서 응답으로 보내줄 데이터만 변화시켜 준 것 뿐이다. 물론 이는 앞서 소개한 "YouTube Shorts" 에서 영감을 얻어 구현해보았다.

더 좋은 구현 방법이 있는지, 더 나은 코드가 있는지 추후 알아볼 것이다.


생각정리및 다음 포스팅 예고

최근에 개발공부를 하는데 있어 집중력도 흐트러지고 생각이 많아져 여유롭게 공부하는 시간을 가졌다. 그러는 김에, 지난번 알아보았던 "Offset-Based-Pagination"과 다르게 "Cursor-Based-Pagination"은 어떤 특징이 있고 어떻게 nest에서 구현할 수 있는가에 대해 천천히 알아보고자 하였다.

데이터베이스에 관한 공부를 깊게 해본적이 없었고, 아직 typeorm을 편하게 사용할 수준은 안되었지만 이번 공부를 통해서, 동시에 포스팅 작성을 하면서 데이터베이스와 쿼리에 대해서 조금 더 다가갈 수 있었고, 어떠한 기능을 구현하는데 있어 어떤 생각으로 접근할 수 있느냐에 대한 감각도 느끼게 되었다.

"페이지네이션"의 관점으로 잠시 들어가보면, 어떠한 방법(오프셋과 커서 중)이 더 활용적이고 좋은 방법인가에 대해 생각할 수 있다. 하지만, 이것은 각자의 방법에서 trade-off가 존재하고, 결국 이러한 기능 구현은 클라이언트의 요구에 따라 민감하게 적용된다는 것 또한 깨닫게 되었다.

아무튼, 이번 포스팅을 계기로 여태껏 클라이언트단에서만 중요할줄 알았던 커서 기반의 페이지네이션을 백엔드 관점에선 어떻게 바라보고 클라이언트단에서 요구하는 데이터를 어떻게 넘겨주게 될 것인가에 대해 알아볼 수 있었다. 더 좋은 내용 혹은 수정사항이 있다면 추후 수정하여 업데이트 할 예정이다.

💨 긴 글 읽어주셔서 감사합니다. 다음 포스팅(#2) 에서 계속됩니다..(혹시나 계신다면...)


다음 포스팅 바로가기 여기 클릭 !!



참고 자료 : 커서-기반 페이지네이션 구현하기 __minsangk.log

도움을 주신 분: 허재(Alen)님 (kakao mobility developer)

profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

0개의 댓글