본격적인 포스팅에 앞서 예전에 했던 프로젝트에서 인피니티 스크롤을 어떻게 구현했었는지 찾아보았다.
※ 혐오 주의 ※
scrollsize = 10;
int start = (pageNum * scrollsize);
int end = start + scrollsize;
List<Post> list;
List<Post> plist = new ArrayList<>();
if (categoryId == 100) {// 전체 게시물 출력
list = postDao.getPostByTempAndCategoryIdNotAndStatusNotOrderByCategoryIdAscCreateTimeDesc(temp, 102,0);
if (list.size() >= start) {
int newend = list.size() - start;
if (newend / scrollsize > 0) {// 적어도 10개는 있음
for (int i = start; i < end; i++) {
plist.add(list.get(i));// 페이지에 맞는 게시물만 뽑아서 보내기
}
} else {// 몫 없이 나머지만 있음
for (int i = start; i < start + newend; i++) {
plist.add(list.get(i));// 페이지에 맞는 게시물만 뽑아서 보내기
}
}
}
.......
뭣 모르고 기능만 완성하겠다고 달려든 과거의 나를 반성하게 만드는 코드다. 아나콘다 같은 메소드명, 무수한 if, for의 난무... 유지 보수는커녕, 읽히지도 않고, 보기도 싫어진다. 아무튼, 당시에는 JPA에서 지원하는 페이징 관련 기법을 사용하지 않고 Paging을 구현했었다.
JPA에는 org.springframework.data.domain의 Page와 Slice로 페이징과 인피니티 스크롤을 구현할 수 있고 둘의 차이점은 다음과 같다.
Page : 데이터의 총 개수 및 전체 페이지 수를 알 수 있다. (카운트 쿼리가 발생)
Slice : 다음 슬라이스의 존재 여부를 알 수 있다. (카운트 쿼리 발생 X)
카운트 쿼리는 다른 쿼리보다 필요한 리소스가 크므로, 데이터 양이 많아짐에 따라 성능 이슈가 발생할 수도 있다!
그렇다면 QueryDSL에서는 어떻게 구현하는지 알아보도록 하자!
Query DSL의 페이징과 인피니티 스크롤
[Custom 인터페이스] (Repository가 상속 받음)
public interface UserRepositoryCustom {
Page<UserResponse> getUserPaging(Pageable pageable);
Slice<UserResponse> getUserScroll(Pageable pageable);
}
Page와 Slice를 사용할 때는 Pageable 객체를 매개변수로 사용하여, 클라이언트에서 "page=(페이지 숫자)&size=(게시물 수)"와 같이 page, size를 지정해서 넘겼을 때 그 규격에 맞게 결괏값을 리턴해 줄 수 있다.
[Custom의 구현 클래스 - Page메서드]
@Override
public Page<UserResponse> getUserPaging(Pageable pageable) {
QueryResults<User> result = queryFactory
.selectFrom(user)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetchResults();
List<UserResponse> content = new ArrayList<>();
for (User eachUser : result.getResults()) {
content.add(new UserResponse(eachUser));
}
long total = result.getTotal();
return new PageImpl<>(content, pageable, total);
}
일단, querydsl에서 지원하는 query내용은 비슷하지만 마지막에 fetchResults()라는 메소드를 사용해서 QueryResults 타입으로 반환받는다. QueryResults은 페이징관련 내용이 포함된 타입이라고 한다.
pageable 객체를 통해 클라이언트에서 보낸 Offset(Page)과 PageSize(size)를 이용해 각각 offset, limit 라는 메서드에 인자 값으로 사용하고, 이렇게 반환된 result에서 getResults(), getTotal() 메소드를 통해 내용과 전체 페이지 수에 접근이 가능하다.
(getResults()는 User도메인 내용을 그대로 담고 있으므로 Dto객체로 변환해주는 과정을 거친다.)
마지막으로 반환하는 타입은 PageImpl <> 타입이고 인자 값은 (내용, pageable, 페이지수) 순서대로
다음은 page=1&size=1로 요청했을 때 얻어지는 결과이다.
{
"content": [
{
"id": 2,
"email": "user1230@never.com",
"name": "Clone1230",
"nickname": "nickname1230",
"city": "seoul",
"street": "1230street",
"detail": "room 11230",
"year": 2008,
"month": 7,
"day": 2
}
],
"pageable": {
"sort": {
"sorted": false,
"unsorted": true,
"empty": true
},
"pageSize": 1,
"pageNumber": 1,
"offset": 1,
"paged": true,
"unpaged": false
},
"last": false,
"totalElements": 100,
"totalPages": 100,
"number": 1,
"sort": {
"sorted": false,
"unsorted": true,
"empty": true
},
"first": false,
"numberOfElements": 1,
"size": 1,
"empty": false
}
크게 content와 pageable에 대한 내용으로 구분되고, 이 pageable 내부의 값을 이용해 클라이언트에서 페이지 구성에 활용하면 된다.(첫 페이지, 마지막 페이지 여부, 전체 페이지 수, 게시물 수 등 다수의 내용을 담고 있다.)
[Custom의 구현 클래스 - Slice메소드]
public Slice<UserResponse> getUserScroll(Pageable pageable) {
QueryResults<User> result = queryFactory
.selectFrom(user)
.offset(pageable.getOffset())
.limit(pageable.getPageSize() + 1)
.fetchResults();
List<UserResponse> content = new ArrayList<>();
for (User eachUser : result.getResults()) {
content.add(new UserResponse(eachUser));
}
boolean hasNext = false;
if (content.size() > pageable.getPageSize()) {
content.remove(pageable.getPageSize());
hasNext = true;
}
return new SliceImpl<>(content, pageable, hasNext);
}
Page와 차이가 있는 부분을 살펴보도록 하자. 일단 지나치기 쉬운 게 limit메서드의 +1 부분이다. 앞서 말했듯 Slice는 카운트 쿼리가 없는 대신에 게시물이 더 존재하는지 알아야 한다. 따라서 요청한 size보다 1개 더 넣어줘서 반환된 개수를 통해 다음 페이지 여부를 판단한다.
필자는 Boolean 타입의 hasNext 변수로 이를 체크하여 마지막 반환 값에 넣어주었다. 반환 값은 SliceImpl <> (내용, pageable, 다음 페이지 여부)가 되겠다.
{
"content": [
{
"id": 2,
"email": "user1230@never.com",
"name": "Clone1230",
"nickname": "nickname1230",
"city": "seoul",
"street": "1230street",
"detail": "room 11230",
"year": 2008,
"month": 7,
"day": 2
}
],
"pageable": {
"sort": {
"sorted": false,
"unsorted": true,
"empty": true
},
"pageSize": 1,
"pageNumber": 1,
"offset": 1,
"paged": true,
"unpaged": false
},
"number": 1,
"sort": {
"sorted": false,
"unsorted": true,
"empty": true
},
"first": false,
"last": false,
"numberOfElements": 1,
"size": 1,
"empty": false
}
Page와는 다르게 전체 게시물 수, 페이지 수 관련 정보는 반환하지 않는다는 것을 확인할 수 있다.
이렇게 Query DSL을 이용해 페이징과 인피니티 스크롤을 구현하는 방식에 대해 알아보았다. 생각보다 간편하게 구현이 가능하고, Condition을 매개변수로 받아 동적 쿼리도 작성할 수 있기 때문에 확장이나 유지보수 하기에 수월할 것으로 판단된다.