TroubleShooting & Study/SpringBoot

[Pagination] Custom Cursor-based Pagination (정렬된 커서기반 페이지네이션)

DH_0518 2024. 5. 20. 11:39

우리는 정렬된 데이터의 수가 많을 때, offset-based pagination을 사용하기보다는 cursor-based pagination을 사용한다. 이를 통해 '데이터의 변화가 있을 때 중복 데이터 노출 문제'와 'offset으로 인한 성능 이슈'를 해결할 수 있다.

 

하지만 실제 프로젝트에서는 단순히 column의 id를 기준으로 정렬된(대부분 최신순이겠죠?) 데이터만을 사용하는 경우는 거의 없을 것이다.

예를 들어 다음과 같은 엔티티가 있다고 가정해 보자

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "product")
public class Product extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "name")
    private String name;
    
    @Column(name = "price", length = 10000)
    private Integer price;
    
    ...
}

 

클라이언트가 "우리 서비스에서는 상품을 조회할 때 가격이 높은 순으로 정렬되었으면 좋겠어"라는 요청이 들어온다면 어떻게 할 것인가?

db에 저장된 상품들은 모두 최신순으로 쌓이고 있기에, 평소에 하던 대로 id를 cursor로 사용하여 pagination을 구현하면 '가격순' 정렬이 불가능하다.

이렇게, 단순하더라도 특정 컬럼을 기준으로 정렬을 해야 하는 상황에서 우리는 어떻게 cursor 기반 페이지네이션을 사용할 수 있을지 알아보자

 

 

 

 

Custom Cursor

 

 

우리는 id를 cursor로 사용하면서 원하는 컬럼에 대해 정렬하기 위해 매번 db를 갈아엎을 수는 없다(실제로 필자는 n시간마다 정렬된 데이터를 사용해야하는 기능 때문에, spring batch를 사용해서 특정시간마다 조건에 맞도록 정렬한 후 테이블 데이터 전체를 갈아 엎어야 하는건가.. 라는 생각까지 했었다..).

그렇다면 어떻게 특정 컬럼에 대해 정렬시킬 수 있을까? 정답은 바로 정렬 기준이 되는 컬럼을 사용해서 'Cursor'를 만드는 것이다.

 

Cursor의 조건

  • Table에서 고유한 컬럼이어야 한다
    • 고유한 컬럼은 'id', 'createdAt', 'updatedAt' 등이 있다
  • 의도한 대로 정렬이 가능해야 한다
  • 비교가 가능해야 한다
    • (java 기준) 비교 가능한 자료형은 String, Primitive, Date and Time 등이 존재한다

 

 

 

 

이제 위의 조건을 토대로, MySQL에서 'Product' 테이블을 'Price'를 기준으로 내림차순 조회하기 위한 커서를 만들어보자

 

Cursor 생성

  • 필요한 mysql 함수
    • CONCAT : 문자열을 합치는 MySQL 내장함수
    • LPAD : 문자열이나 숫자를 지정된 길이의 문자열로 채움
  • Cursor 
    • 고유한 컬럼: Unique 하기 위해 'id' 컬럼을 사용한다
    • 의도한 대로 정렬: 'Price' 컬럼을 사용하고 앞쪽에 위치시킨다
    • 비교 가능한 자료형: String을 사용한다
    • cursor: '0000PRICE0000ID'
'custom_cursor' = 
    SELECT
        p.id, 
        p.name, 
        p.price,
        concat(lpad(cast(p.price as char), 5, '0'), lpad(cast(p.id as char), 5, '0')) as 'cursor'
    FROM
        product p
    ORDER BY 
        p.id DESC 
    LIMIT 5;

 

 

 

 

Cursor 결과

DB에 저장된 데이터
cursor 조회 결과

결과를 보면 알 수 있듯이 cursor는 '0000PRICE0000ID' 와 같은 형태이고 price와 id에 종속되기에 id와 price의 정렬에 따라서 cursor 또한 같이 움직인다는 것을 알 수 있다. 이렇게 cursor를 사용하면 price가 앞쪽에 위치하므로 order by절에서 price 컬럼을 정렬시켜 내가 원하는 가격순 정렬이 가능하고, 뒤쪽에 id를 포함하고 있으므로 unique 한 값이어서 커서로 사용할 수 있다.

 

 

 

 

 

추가적으로, cursor가 굳이 select절에 들어갈 필요는 없다. 위에서는 cursor를 직접 확인하기 위해 select절에서 사용했지만, where이나 having에서 cursor를 조건으로만 넣더라도 우리가 원하는 대로 정렬이 되기 때문이다.

custom-cursor
cursor를 조건절에서만 사용

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Reference

 

커서 기반 페이지네이션 (Cursor-based Pagination) 구현하기

사실 처음에는 이 주제로 포스트를 쓰려고 했던건 아니고 Apollo GraphQL 에서 커서 기반 페이지네이션 구현 을 주제로 글을 쓰려고 했습니다. 그런데 막상 찾아보니 백엔드-프론트엔드를 함께 고려

velog.io

 

Cursor based pagination with arbitrary ordering

If you’re already convinced you should use cursor based pagination over offset pagination and just want to know how to order data with…

medium.com