Java Spring JPA를 활용한 커서 기반 페이징 최적화

SeHun.J·2024년 10월 22일

LIMIT, OFFSET의 함정

Java Spring JPA로 개발을 하신 분들이면, 아마 Pageable이나 Slice로 더 익숙하실 것 같네요. SQL에서 사용되는 LIMIT, OFFSET은 보통 한세트입니다. LIMIT는 해당 값만큼의 레코드만 가져오고, OFFSET은 해당 값만큼의 앞의 레코드를 버린다입니다.

그래서 보통 페이지를 구현하려면, 한페이지에 5개의 게시글을 조회한다. 라고 하면
SELECT * FROM Post LIMIT 10 OFFSET 5;
위는 2번째 페이지의 글을 조회하는 SQL 쿼리문입니다. 10개의 레코드를 가져와서 앞의 5개 레코드를 버리므로 2번째 페이지의 5개 게시글이 조회됩니다.

뭔가 이상하지 않나요?
이런 구조면 10번째 페이지를 가져오려면, 1~10번 페이지를 전부 읽어와서 1~9번 페이지를 버려야 합니다. 뒤로 갈수록 한페이지를 읽기 위해 더 많은 페이지를 가져오고 버려야 하는 겁니다.

Spring JPA의 Pageable, Slice

JPA에서 사용하는 Pageable이나 Slice는 성능에 차이가 있습니다. Pageable은 전체 페이지의 갯수를 알기 위해서 추가적으로 SELECT COUNT(*) 쿼리문을 통해 총레코드 수를 읽어오기 때문입니다.

Slice는 전체 페이지의 갯수는 알 수 없지만, 바로 다음페이지의 존재유무만 체크하는 방식으로 5개의 레코드 단위로 페이지를 읽는다면 거기에 +1만큼 추가로 조회하도록 하여 6개의 레코드가 존재하면 다음 페이지가 존재한다고 알려주는 방식입니다.

여기서 함정은 Pageable이나 Slice는 모두 LIMIT,OFFSET을 활용한다는 겁니다. 뒤로 갈수록 스캔하는 레코드의 수가 증가하므로 데이터베이스의 부담이 증가하는 구조입니다.

그럼 어떻게 최적화할 수 있을까요?

결국 페이지를 효율적으로 가져오려면, 어떤 기준이 필요합니다. 그리고 그 기준은 인덱스로 지정되어 있는 컬럼이 더 효율적이겠네요.

커서 기반 페이징(Cursor-based Pagination)

OFFSET 방식은 페이지가 깊어질수록 더 많은 데이터를 스캔해야 하기 때문에 성능이 저하됩니다. 커서 기반 페이징은 이를 해결하기 위해 마지막으로 본 데이터의 고유 ID를 커서로 사용하여 그 이후의 데이터를 빠르게 가져옵니다.

예를 들어, 마지막 게시글 생성날짜를 기억하고, 그 다음 페이지를 요청할 때는 WHERE date > {last_fetched_date} 조건 방식으로 이전 페이지의 마지막 게시글 이후의 데이터를 가져옵니다. 이렇게 하면 OFFSET을 사용하지 않고도 성능을 최적화할 수 있습니다.

ID TITLE  TIMESTAMP
0  게시글0 0
1  게시글1 3
2  게시글2 4
3  게시글3 6
4  게시글4 9
5  게시글5 11
6  게시글6 13

이런 상황에서 3개의 레코드가 한페이지일 때 커서 기반 페이징을 해봅시다.
첫번째 페이지는 다음과 같은 SQL문으로 조회됩니다. 첫번째이므로 기준이 없어서 LIMIT를 사용합니다.

SELECT * FROM Post ORDER BY timestamp ASC LIMIT 3;

조회결과, ID는 0, 1, 2를 가진 레코드가 나오겠네요. 이제 다음 페이지부터는 마지막 타임스탬프인 4를 기준으로 검색합니다.

SELECT * FROM Post WHERE timestamp > 4 ORDER BY timestamp ASC LIMIT 3;

두번째 페이지에서도 LIMIT를 활용하면 마지막 게시글 날짜보다 작은 레코드를 3개만 가져올 수 있습니다.

Spring JPA에서 커서 기반 페이징 적용하기

JPA에서 이러한 커서 기반 페이징을 위한 구현체는 따로 없는 걸로 알고 있습니다. JPA에서 제공하는 건 모두 OFFSET-based Pagination인 셈이죠.

없다면 직접 만들면 그만입니다.

Slice<Post> findFirstPage(Pageable pageable);

@Query("SELECT p FROM Post p WHERE p.createdAt > :lastCreatedAt ORDER BY p.createdAt ASC")
Slice<Post> findNextPage(@Param("lastCreatedAt") LocalDateTime lastCreatedAt, Pageable pageable);

JpaRepository를 상속 받은 Repository Interface의 코드입니다.
그런데 Pageable을 안쓴다고 하면서 코드에는 사용되어 있어 의문이실 수 있습니다.

Pageable은 PageRequest.of(int pageNumber, int sizeSize, Sort)로 만들 수 있는데요.
이때, pageNumber가 0이면 OFFSET은 생략됩니다. 그러면 저희가 원하는 LIMIT만 추가할 수 있는 셈입니다.

// Service
// 첫번째 페이지는 커서 기준이 없음
PageRequest pageRequest = PageRequest.of(0, 5);
Slice<Post> posts = postRepository.findFirstPage(pageRequest);

// 두번째 페이지부터는 아래와 같이 검색
PageRequest pageRequest = PageRequest.of(0, 5);
Slice<Post> posts = postRepository.findNextPage(lastCreatedAt, pageRequest);

서비스 레이어에서는 다음과 같이 첫번째 페이지를 탐색하는지 아닌지에 따라 나눠서 처리하면 됩니다.
Slice 구현체를 사용하는 이유는 SELECT COUNT(*)를 피하면서, 다음 페이지의 존재 유무를 알기 위해서 사용했습니다.

주의사항

  1. 커서 기반 페이지네이션은 결국 Cursor라는 기준점이 필요하다.

내용을 읽다보면 이런 의문이 생깁니다. 처음부터 3페이지의 게시글을 가져오게 하려면 어떻게 해야할까? 첫조회시에는 기준점도 없기 때문에 1~3페이지의 모든 글을 가져온 후 1~2페이지는 버려야 합니다.

그런데 과연 첫조회만 그럴까요? 커서는 검색하고자 하는 페이지의 직전값이 기준이 되기 때문에 무작위 페이지를 불러오게 할 경우에도 문제가 생깁니다. 간단하게 1페이지 -> 2페이지-> 3페이지 -> 2페이지의 경우를 생각해봅시다. 3페이지까지는 순서대로 페이지를 불러왔으므로 문제가 안되지만, 3페이지에서 다시 2페이지로 넘어가려면 1페이지의 마지막데이터가 필요합니다. Frontend에서 불러온 모든 페이지를 관리하고 있으면 해결 가능한 문제이긴 하지만 여기서 말하고 싶은 건 페이지를 순서대로 읽어야 커서를 활용한 최적화가 가능하다. 입니다.

그러므로, 커서 기반 페이지네이션은 무한스크롤, 즉 Infinite Scrolling의 케이스에 가장 적합합니다. 반드시 첫페이지부터 순차적으로 로딩을 하기 때문입니다.

그렇다고 반드시 무한스크롤에만 커서 기반 페이지네이션을 적용할 수 있는 건 아닙니다. 기준점이 없는 경우에는 Offset-Based Pagination을 사용하고, 기준점이 있을 땐 Cursor-Based Pagination을 사용하게 하면 간단하게 해결이 가능합니다.

  1. Cursor는 고유한 값(Unique)이어야 한다.

만약, 커서의 기준이 되는 값이 중복될 수 있으면 어떻게 될까요? 커서와 같은 값은 다음 페이지에서 제외됩니다. 그렇다고 SQL의 조건에서 기준이 되는 커서도 포함시켜서 조회하면 중복된 값이 나올 수 있고, 중복된 값이 페이지 크기보다 큰 경우 다음페이지 탐색 자체가 불가능해집니다.

profile
취직 준비중인 개발자

0개의 댓글