💡발단
우리 프로젝트의 프론트엔드는 모바일 안드로이드로, 대부분의 조회 페이지가 무한 스크롤로 구현된다.
즉, 오프셋이 쓰이지 않고 커서를 이용해 페이징한다.
마지막으로 응답했던 레코드의 id 값을 커서(커서 아이디)로 사용한다.
맨 처음엔 커서 아이디를 0으로 요청 -> 아이디 1~10을 갖는 레코드를 응답하고,
그 다음엔 커서 아이디를 10으로 요청 -> 아이디 11~20 을 갖는 레코드를 응답하는 식이다.
이 상황에서 조회 기능들을 구현하면서 배우게 된 Page의 단점과 Slice의 장점을 서술하고자 한다.
❗Page, Pageable의 대부분의 필드는 커서 페이징에 필요하지 않다.
애초에 커서 페이징에는 offset이 쓰이지 않는다.
offset이 없으므로 getOffset, getPageNumber 등 많은 메서드들이 아무 의미를 갖지 않는다.
또한 pageNumber, unpaged, first 등의 필드 또한 의미가 없다.
이러니 명시성의 문제도 생긴다.
Pageable을 사용한다는건 암시적으로 offset을 쓸 가능성이 있다고 말하는 것과 같다.
동료 개발자들에게 오해를 불러일으킬 여지가 있다고 생각한다.
결론부터 말하자면, 나에게 필요한 필드는 요청할 레코드의 개수(size)와 정렬 방식(sort) 뿐이었다.
아래는 pageable을 쓰면서 고작 getPageSize 메서드밖에 쓰지 않는 코드.
정말 아무 생각 없이 Pageable과 Page를 썼던 것 같다. (김영한님 강의에서 분명히 Slice를 배웠는데..)
public Page<Something> findAllSomething(
Pageable pageable,
Long cursorId
) {
JPAQuery<Spot> query = jpaQueryFactory.selectFrom(something)
// WHERE 절 ...
.where(ltCursorId(cursorId)) // WHERE id < 커서 아이디 (내림차순 정렬)
// ...
.limit(pageable.getPageSize()); // LIMIT Pageable.pageSize
// ...
}
sort는 쿼리 파라미터로 개별적으로 받기로 했고,
여기서는 size,
결국 남는건 size, 이것만 추출해서 나만의 Slice 기반 쿼리를 작성하게 됐다.
❗ Page의 단점
Page 기반 쿼리에서는 FETCH JOIN으로 조회했을 때 치명적인 오류가 생길 수 있다.
N+1 을 가져오면서 row를 N개를 포함한 모든걸(즉 연관관계 엔티티까지)로 인식하고, 개수를 잘못 인식하기 때문
default_batch_size로 한계를 이겨낼 수 있지만, 그래도 1+1문제 (추가적인 WHERE IN ... 쿼리) 가 생기는건 어쩔 수 없다.
Slice를 사용하면 커서 페이징의 장점을 극대화할 수 있다.
❗PageExecutionUtils의 단점 : 틀린 카운트 값이 발생
클라이언트에서 각 페이지에서 총 레코드 수(Page의 totalElements)를 필요로 하는 상황.
그런데 PageableExecutionUtils.getPage는 기본적으로 카운트 쿼리를 최적화하기 위해 마지막 페이지일 때 카운트 쿼리를 날리지 않는다. 오프셋 페이징에서는 이게 성능 최적화일지 모르겠지만, 커서 페이징에서는 오류를 낳는다.
getPage 메서드의 내용을 간단히 정리해보면 다음과 같다.
항상 isUnpaged == false, offset == 0이므로 이 값들은 무시하겠다.
위의 조건문 (pageable.isUnpaged() || pageable.getOffset() == 0)에서 모든 분기가 처리되므로 그 아래의 문장들은 볼 필요 없다.
1. content, pageable, totalSupplier이 NULL이 아닌지 검사
2. 전체 레코드가 요청한 페이지 사이즈보다 작을 경우, 현재 레코드 수를 카운트로 설정 : 이 부분이 문제. 항상 전체 레코드 개수를 알고싶은건데 현재 응답 레코드 개수만 알려주게 된다.
3. 전체 레코드가 요청한 페이지 사이즈보다 클 경우, 전체 카운트 쿼리 결과를 카운트로 설정
2번째 부분 때문에 PageExecutionUtils는 사용하기 어렵다.
PageImpl을 사용해서 Page를 생성할 수 있기는 하다.
그러나 앞서 말했던 Page, Pageable의 단점
⭐ Slice 방식 구현 : SliceExecutionUtils
만들어보기
Slice 방식의 흐름은 다음과 같다.
1. 사이즈(size)를 요청 받는다.
2. 사이즈 + 1개를 조회해 마지막 페이지인지 여부를 확인한다
3. 확인했다면 content의 마지막 원소를 삭제하여 원래의 사이즈대로 응답되도록 변경한다
public class CustomSliceExecutionUtils {
public static <T> Slice<T> getSlice(List<T> content, int size) { // 인자 : 쿼리 응답 DTO 리스트인 content, 요청된 사이즈인 size
boolean hasNext = false; // 다음 레코드가 있는지 여부
if (content.size() > size) { // content.size가 최대일 경우: 항상 page size + 1 이고 다음 레코드가 있다.
content.remove(size); // limit걸 때 +1 했던 마지막 레코드를 삭제
hasNext = true;
}
return new SliceImpl<>(content, Pageable.ofSize(size), hasNext);
}
public static int buildSliceLimit(int size) { // 언제나 요청한 size + 1개 조회
return size + 1;
}
}
밑의 buildSliceLimit 메서드부터 보자.
Slice 방식에선 항상 쿼리를 날릴 때 요청된 사이즈 + 1개를 요청한다. 그럼으로써 다음 레코드가 있는지 알 수 있다.
요청된 size가 10이고, 다음 페이지 혹은 레코드가 있다고 하자.
그러면 content.size 값은 11개가 된다.
조건문을 타고 content.remove(size)를 통해 맨 마지막 레코드를 삭제함으로써 처음에 요청했던 10으로 조정한다.
그리고 hasNext를 true로 변경한다.
아래는 QueryDSL 코드.
public Slice<Something> findAllSomething(int size) {
JPAQuery<Quiz> query = queryFactory.selectFrom(something)
// ...
.limit(CustomSliceExecutionUtils.buildSliceLimit(size));
return CustomSliceExecutionUtils.getSlice(query.fetch(), limit);
}
💡 Slice 응답 DTO 만들기
현재 프로젝트에서는 리스트 조회 API에서 엔티티를 감싼 DTO를 응답 DTO로 다시 한번 감싼다.
그럼으로써 count, isLast 값을 통일성 있게 반환한다.
커서 페이징에서 더 중요한건 isLast이지만, count도 가끔 쓰이는 경우가 있기 때문에 두가지 버전으로 만들었다.
생성자로 slice와 totalElements를 인자로 받아서 응답 DTO를 만든다.
아래 코드의 totalElements는 메인 쿼리와 별개로 카운트 쿼리를 실행한 결과여야 한다.
커서 페이징의 장점은 'WHERE절을 통한 특정 레코드 조회' 이기 때문에, 총 레코드 개수를 기본적으로는 알 수 없다. (참고 : 1. 페이징 성능 개선하기 - No Offset 사용하기) 그래서 카운트 쿼리를 따로 날려야 한다.
@Builder
@Getter
public class SliceResponse<T> { // 총 개수가 필요한 클라이언트에 대한 응답 DTO
long count;
boolean isLast;
List<T> content;
public static <T> SliceResponse<T> of(Slice<T> slice, long totalElements) { // Slice + 카운트 쿼리
return PageResponse.<T>builder()
.count(totalElements)
.isLast(slice.isLast())
.content(slice.getContent())
.build();
}
// ...
}
총 레코드 개수를 반환할 필요가 없다면 (이상적인 커서 페이징), slice만을 인자로 받아 content, isLast 필드에 값을 채운다.
@Builder
@Getter
public class NoCountPageResponse<T> { // 무한 스크롤 && 총 개수가 필요없는 클라이언트에 대한 응답 DTO
boolean isLast;
List<T> content;
public static <T> NoCountPageResponse<T> of(Slice<T> slice) { // 오직 Slice. 카운트 쿼리 최적화
return NoCountPageResponse.<T>builder()
.isLast(slice.isLast())
.content(slice.getContent())
.build();
}
}
참고:
- 1. 페이징 성능 개선하기 - No Offset 사용하기
- 커서 기반 페이지네이션 (Cursor-based Pagination) 구현하기
- [Spring Boot] QueryDSL 커서 기반 페이지네이션 구현해보기
- https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/domain/Pageable.html
'Back-end > Spring Boot' 카테고리의 다른 글
[Spring JPA] 페이지네이션 일대다 FETCH JOIN 문제와 default_batch_size (0) | 2023.09.25 |
---|---|
[Spring JPA] 엔티티 컬렉션 필드를 초기화해야 하는 이유 (0) | 2023.09.18 |
[Spring JPA] Hibernate에서 지원하지 않는 MySQL 랜덤 함수 직접 만들기 + Expressions 파헤쳐보기 (0) | 2023.09.06 |
[Spring Boot] 개발 환경 분리와 ddl-auto 재앙 방지 + @Profile (0) | 2023.09.03 |
[Spring Boot] @Valid 유효성 검사 (jakarta validation) (0) | 2023.08.27 |