개요
페이지네이션과 무한 스크롤은 사용자가 컨텐츠를 조작할 때 중요한 역할을 합니다. 사용자가 특정 정보를 탐색하는 과정에 영향을 미치기 때문에 UX에서 매우 중요한 요소라고 볼 수 있습니다.
페이지네이션
페이지네이션은 일반적으로 웹 사이트나 애플리케이션에서 긴 목록이나 검색 결과를 페이지 단위로 분할하는 방법입니다. 이를 통해 사용자는 전체 목록을 한 번에 볼 필요 없이 필요한 부분만 볼 수 있습니다. 이는 사용자가 페이지를 넘기는 데 필요한 시간을 줄이고, 빠르게 원하는 정보를 찾을 수 있도록 도와줍니다.
무한 스크롤
반면, 무한 스크롤은 사용자가 스크롤을 내릴 때마다 새로운 컨텐츠가 계속해서 로드되는 방식입니다. 이것은 사용자가 스크롤을 내리는 행위, 즉 새로운 컨텐츠를 필요로 할 때마다 일정량의 데이터를 불러오는 방식이기 때문에 보다 유저 친화적인 방식이라 할 수 있습니다. 일반적으로 스크롤링을 통해 정보를 탐색하는 모바일 환경에 많이 사용됩니다.
페이지네이션과 무한 스크롤은 UX에서 각각 장단점이 있기 때문에 서비스의 특성 및 사용자 용도 따라 적절한 방식을 선택하여 컨텐츠를 제공하는 것이 중요합니다.
Spring에서는 Page와 Slice를 통해 각각 페이지네이션, 무한 스크롤 방식을 위한 정보(총 페이지, 마지막 페이지 여부 등)가 담긴 데이터를 제공해줄 수 있습니다.
Spring data JPA에서는 반환 타입과 파라미터에 Pageable, Sort 객체를 선언함으로써 Page 및 Slice 객체를 반환 받을 수 있습니다. 해당 방식은 본 포스팅에서 다루지 않고 QueryDsl에서 Page, Slice 객체를 반환하는 방법에 대해 설명할 예정이니 관심 있으신 분들은 Spring data JPA와 Page, Slice 키워드를 조합하여 찾아보는 것을 권장합니다.
Page<Member> findByUsername(String name, Pageable pageable);
Slice<Member> findByUsername(String name, Pageable pageable);
List<Member> findByUsername(String name, Pageable pageable);
List<Member> findByUsername(String name, Sort sort);
📌Pageable
Page와 Slice 인스턴스를 생성하기 위해 공통적으로 필요한 정보가 있습니다. 해당 정보들은 다음과 같습니다.
- 페이지 번호 - page
- 한 페이지에 불러올 데이터 건수 - size
- 정렬 조건 - sort
위와 같은 정보들을 넘겨줄 때 다음과 같이 Pageable 타입 인스턴스를 사용하게 됩니다.
Pageable의 구현체로 org.springframework.data.domain.PageRequest 를 사용합니다. PageRequest 인스턴스를 생성하는 방법은 static 메서드인 of를 사용하여 직접 생성할 수 있습니다. of 메서드는 다음과 같이 오버로딩 되어있습니다.
PageRequest of(int page, int size)
PageRequest of(int page, int size, Sort sort)
PageRequest of(int page, int size, Direction direction, String... properties)
Spring Data 가 제공하는 페이징과 정렬 기능
Spring Data는 쿼리 파라미터를 통해 Pageable을 받을 수 있도록 Argument Resolver가 정의되어 있습니다. 다음의 요청 파라미터 규칙을 지키면 PageRequest를 Controller의 인자로 받아서 사용할 수 있게 됩니다.
@GetMapping
public Page<Member> list(Pageable pageable) {
// 파라미터에서 Pageable 인스턴스를 받아 사용
Page<Member> page = memberRepository.findAll(pageable);
return page;
}
요청 파라미터
- 예) /?page=0&size=3&sort=username,desc
- page: 현재 페이지, 0부터 시작한다.
- size: 한 페이지에 노출할 데이터 건수
- sort: 정렬 조건을 정의한다. 예) 정렬 속성,정렬 속성...(ASC | DESC), 정렬 방향을 변경하고 싶으면 sort 파라미터 추가 ( asc 생략 가능)
개별 설정
@GetMapping("/page")
public PageResponse<RecruitmentInfo> search(
@PageableDefault(sort = "createdDate", direction = DESC) Pageable pageable
) {
return recruitmentSearchService.search(pageable);
}
- @PageableDefault 어노테이션을 통해 Pageable 구현체의 기본값을 설정해 줄 수 있습니다.
- 위 코드는 쿼리 파라미터로 sort 값을 받지 못할 경우, 기본적으로 'createdDate' 필드를 기준으로 내림차순 정렬 조건이 삽입됩니다.
글로벌 설정
- application.yml 파일에 애플리케이션 전역적으로 설정되는 기본 값을 할당할 수 있습니다.
- spring.data.web.pageable.default-page-size=20
- 기본 페이지 사이즈를 나타냅니다.
- spring.data.web.pageable.one-indexed-parameters: true
- page 정보는 기본적으로 0부터 시작합니다. 하지만 마지막 페이지 번호는 1부터 시작한 값이 사용됩니다.
ex) 마지막 페이지 번호가 6일 때 마지막 페이지를 보고싶다면 /?page=5 를 넘겨줘야 마지막 페이지를 조회. - one-indexed-parameters 옵션을 true로 설정할 경우, page 정보가 1부터 시작됩니다.
ex) 마지막 페이지 번호가 6일 때 마지막 페이지를 보고싶다면 /?page=6 를 넘겨줘야 마지막 페이지를 조회합니다.- 이 방법은 단순하게 파라미터로 받은 page 값에 -1을 해주는 것이기 때문에, 실제 Pageable 구현체에 할당되는 page 값은 5입니다.
- page 정보는 기본적으로 0부터 시작합니다. 하지만 마지막 페이지 번호는 1부터 시작한 값이 사용됩니다.
- spring.data.web.pageable.default-page-size=20
굳이 Pageable 객체를 파라미터로 사용하지 않고, 직접 클래스를 만들어서 처리해도 됩니다. Argument Resolver에 해당 클래스를 등록하고 쿼리 파라미터 값을 추출하여 클래스 필드에 값을 삽입하여 넘겨주면 됩니다.
이와 같은 방법을 통해 Pageable 타입의 인스턴스를 QueryDsl을 사용하는 Repository Layer에 넘겨줄 수 있습니다.
📖 Page
Page 객체를 생성하기 위해선 Page 인터페이스를 구현한 PageImpl을 사용합니다. PageImpl의 생성자는 다음과 같습니다.
(List<T> content만을 인자로 받는 생성자가 있지만 내부적으로 빈 Pageable 객체를 생성하여 필드에 할당하게 됩니다. -> 페이징 X)
이 세가지의 파라미터에 대해 살펴보겠습니다.
1. List<T> content
List 자료형의 content는 페이지 번호와 사이즈에 기반하여 불러온 실제 데이터를 말합니다.
예를 들어, size = 10, page = 0 이라면 첫 번째 페이지에 있는 10개의 데이터를 조회하여 List<T> content에 넘겨주면 됩니다.
public void searchPage(Pageable pageable) {
List<Post> content = queryFactory.
.selectFrom(post)
.where(post.name.eq("제목"))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
- 특정 위치에 있는 데이터를 조회할 때 limit - offset 을 사용하는데 pageable에 저장된 offset과 size를 이용하여 조회에 필요한 값을 받아, 해당 데이터를 조회할 수 있습니다.
2. Pageable pageable
앞서 설명했던 page, size, sort 의 정보를 가지고 있는 Pageable 입니다. Pageable은 인터페이스이며 구현체로는 PageRequest를 사용합니다.
PageRequest는 page, size, sort 를 매개변수로 받아 이 정보들을 통해 한 페이지에 포함된 데이터 건수, offset, 이전 페이지 존재여부 등등에 대한 정보들을 여러 메서드를 통해 얻어낼 수 있습니다.
또한, next() | previous() | first() 메서드를 통해 각각 다음, 이전, 처음 페이지에 대한 PageRequest 인스턴스를 받을 수 있습니다.
3. long total
페이지네이션을 위해서는 전체 데이터 건수에 대한 정보가 필요합니다. 조회되는 전체 데이터 개수가 몇개인지 알아야, 페이지번호를 매길 수 있기 때문입니다.
public void searchPage(Pageable pageable) {
List<Post> content = queryFactory.
.selectFrom(post)
.where(post.name.eq("제목"))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// count 쿼리 추가
long total = queryFactory
.select(post.count())
.from(post)
.where(post.name.eq("제목"))
.fetchOne();
// Page 객체 반환
Page<Post> result = new PageImpl(content, pageable, total);
}
따라서 위와 같이 count 쿼리가 별도로 필요합니다. 페이징 대상이되는 데이터에 대해 count를 구해야하므로 content와 동일하게 where 조건절을 삽입하여 전체 데이터 건수를 구합니다.
💻 Code
Count 쿼리 최적화
페이지네이션 방식을 제공하기 위해서는 전체 데이터 건수를 구하기 위해 count 쿼리를 날려야합니다. 하지만 count 쿼리를 생략 가능한 경우가 있습니다.
- 페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
- 마지막 페이지 일 때 (offset + 컨텐츠 사이즈를 더해서 전체 사이즈를 구함)
다음과 같은 상황에서는 count 쿼리를 날리지 않고 페이지네이션에 필요한 데이터를 제공할 수 있습니다.
Spring Data 에서 제공하는 PageableExecutionUtils 추상 클래스는 count 쿼리를 생략해서 처리하는 기능을 가지고 있습니다. 해당 기능은 다음과 같이 사용 가능합니다.
public void searchPage(Pageable pageable) {
List<Post> content = queryFactory.
.selectFrom(post)
.where(post.name.eq("제목"))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> countQuery = queryFactory
.select(post.count())
.from(post)
.where(post.name.eq("제목"))
// Count 쿼리 최적화
Page<Post> result = PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
3번째 파라미터로 count 쿼리를 발생시키는 Supplier를 넘겨주면 필요할때만 Count 쿼리가 발생하게 됩니다.
📑 Slice
Slice도 Page와 마찬가지로 SliceImpl 을 통해 Slice 인스턴스를 생성할 수 있습니다.
첫 번째와 두 번째의 파라미터는 같지만, 세번째 파라미터가 PageImpl과는 다르다는 것을 확인할 수 있습니다.
Slice는 Page와 다르게, 페이지 번호를 매길 필요가 없기 때문에 전체 데이터 건수가 필요하지 않습니다.
단지 다음 페이지의 존재 여부만 필요할 뿐입니다. 다음 페이지가 존재한다면 [더보기] 버튼을 활성화하거나 스크롤을 내렸을 때 다음 데이터를 불러와야하기 때문입니다.
💻 Code
세번째 파라미터로 넘길 boolean 값은 limit에 size + 1 을 통해 구할 수 있습니다. Page에서 사용된 동일한 쿼리를 Slice로 바꿔보겠습니다.
public void searchPage(Pageable pageable) {
int pageSize = pageable.getPageSize();
List<Post> content = queryFactory.
.selectFrom(post)
.where(post.name.eq("제목"))
.offset(pageable.getOffset())
.limit(pageSize + 1)
.fetch();
boolean hasNext = false;
if (content.size() > pageSize) {
content.remove(pageSize);
hasNext = true;
}
// Slice 객체 반환
Slice<Post> result = new SliceImpl(content, pageable, hasNext);
}
예를 들어, 무한 스크롤 방식으로 한 페이지당 5개의 데이터를 조회할 것이라고 가정하겠습니다. 이 때, 특정 offset에서 limit를 6으로 걸어 조회한 결과로 6개의 데이터가 반환된다면, 이는 다음 페이지가 존재한다고 볼 수 있습니다.
반면, 5개 이하의 데이터가 조회되었다면 해당 페이지는 마지막 페이지라고 볼 수 있습니다. 페이지를 추가해도 더 이상 불러올 데이터가 없기 때문입니다.
이와 같은 이유로, limit + 1 조건을 설정하고 반환되는 데이터 개수에 따라 hasNext 의 값을 정하여 파라미터로 넘겨주면 됩니다.
🧷 참고
참고로 Page 인터페이스를 구현한 조회 데이터 객체를 그대로 반환하게 된다면 다음과 같은 형태로 데이터가 반환됩니다.
{
"content": [
{
"id": 20,
"title": "토익 스터디 인원 모집 10",
"author": "kai",
"createdDate": "2023-04-17 02:27:39 AM",
"recruitStatus": "진행중",
"category": "어학",
"tags": [
"토익",
"영어",
"자격증"
],
"totalCount": 7,
"approvedCount": 0,
"profileImage": null,
"replyCount": 0
},
],
"pageable": {
"sort": {
"empty": false,
"sorted": true,
"unsorted": false
},
"offset": 0,
"pageSize": 100,
"pageNumber": 0,
"unpaged": false,
"paged": true
},
"totalPages": 1,
"last": true,
"totalElements": 10,
"size": 100,
"number": 0,
"sort": {
"empty": false,
"sorted": true,
"unsorted": false
},
"first": true,
"numberOfElements": 10,
"empty": false
}
Slice의 경우, totalPages와 totalElements 필드가 없습니다. (Count 쿼리를 날리지 않기 때문)
이처럼 Sort 값이 중복되어 반환되기도 하고, 필요없는 데이터도 포함되어 반환되기 때문에 별도의 클래스를 생성하여 필요한 데이터만 반환해주는 것이 더 깔끔하게 나타낼 수 있습니다.
PageResponse, SliceResponse 생성
public class SliceResponse<T> {
protected final List<T> content;
protected final SortResponse sort;
protected final int currentPage;
protected final int size;
protected final boolean first;
protected final boolean last;
public SliceResponse(Slice<T> sliceContent) {
this.content = sliceContent.getContent();
this.sort = new SortResponse(sliceContent.getSort());
// page 번호가 1번부터 시작되도록
this.currentPage = sliceContent.getNumber() + 1;
this.size = sliceContent.getSize();
this.first = sliceContent.isFirst();
this.last = sliceContent.isLast();
}
}
public class PageResponse<T> extends SliceResponse<T> {
private final long totalElement;
private final int totalPage;
public PageResponse(Page<T> pageContent) {
super(pageContent);
this.totalElement = pageContent.getTotalElements();
this.totalPage = pageContent.getTotalPages();
}
}
PageResponse → JSON 반환
{
"content": [
{
"id": 20,
"title": "토익 스터디 인원 모집 10",
"author": "kai",
"createdDate": "2023-04-17 03:47:16 AM",
"recruitStatus": "진행중",
"category": "어학",
"tags": [
"토익",
"영어",
"자격증"
],
"totalCount": 7,
"approvedCount": 0,
"profileImage": null,
"replyCount": 0
}
],
"sort": {
"sorted": true,
"direction": "DESC",
"orderProperty": "createdDate"
},
"currentPage": 1,
"size": 100,
"first": true,
"last": true,
"totalElement": 10,
"totalPage": 1
}