들어가기 전에
프로젝트를 진행하면서, 빵집의 상세 페이지에서 메뉴 더보기 클릭 시, 해당 빵집에 존재하는 모든 메뉴를 순차적으로 보여주어야 했습니다. 이때, 아래로 쭉 내렸을 때 메뉴가 더 존재한다면 화면에 더 뿌려주고자 무한 스크롤을 구현하였습니다.
참고 사항
하기 예시는 모두 page index가 1부터 시작한다고 가정하고 진행한 것이기 때문에, 만약 page를 0부터 시작하고자 한다면, 변경이 필요할 수 있습니다.
페이징 기법 종류
진행하기 앞서, 간단하게 페이징 기법 종류에 대해 알아보고자 합니다. 먼저 아래 왼쪽 사진의 경우는, 일반적인 paging 기법입니다. 각 index를 눌러 페이지를 호출하고 해당 페이지에 해당하는 데이터를 뿌려줍니다. 반면, 오른쪽 사진의 경우는 더보기 버튼을 클릭하여 다음 데이터가 있다면 뿌려주는 형식으로, 따로 page번호를 명시하고 있지 않습니다.
Page
Paging은 일반적인 페이징 방식입니다. PC 버전에서는 위와 같이 각 페이지의 번호가 쓰여있고, 해당 번호 클릭 시 다음 페이지로 넘어가는 경우가 많습니다. 이런 경우를 Page를 사용해서 구현했다고 볼 수 있습니다. Page를 사용하면, 해당 페이지에 존재하는 데이터 뿐 아니라 총 데이터의 개수 및 총 페이지의 개수를 포함하여 응답을 리턴해줍니다.
- 이로 인해, QueryDSL을 사용할 경우
fetchResult()
를 사용하게 되는데,fetchResult()
는 기존fetch()
내용에 총 데이터 개수 및 페이지 개수를 counting 해주는 것이라고 볼 수 있습니다.- 따로 명시하지 않아도 counting을 해주는 것이라, 단순한 쿼리에서는 사용이 가능하지만 조금만 복잡해져도 스스로 counting을 하지 못해 직접 명시적으로 counting 쿼리를 작성해주어야 할 수 있습니다.
[ 잘못된 예 ]
아래 예시의 경우, groupBy를 사용하고 있어 fetchResults()에 의해 counting을 할 수 없는 쿼리입니다. 이 경우 아래 캡쳐 사진과 같이 antlr.MismatchedTokenException: expecting CLOSE, found ','
에러가 발생하기 때문에 명시적으로 counting하는 형식으로 변경해주어야 합니다.
public Page<BakeryMenuResponse> findBakeryMenuPageableByBakeryId(Long bakeryId, Pageable pageable) {
QueryResults<BakeryMenuResponse> bakeryMenuResponseQueryResults = jpaQueryFactory
.select(Projections.fields(BakeryMenuResponse.class,
menuReviews.menus.id.as("menuId"),
...
menuReviews.rating.avg().as("avgRating")))
.from(menuReviews)
.where(menuReviews.bakeries.id.eq(bakeryId))
.groupBy(menuReviews.menus.id, menuReviews.menus.breadCategories.id, menuReviews.menus.breadCategories.name, menuReviews.menus.name, menuReviews.menus.price, menuReviews.menus.imgPath)
.orderBy(menuReviews.id.count().desc(), menuReviews.rating.avg().desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetchResults();
List<BakeryMenuResponse> content = new ArrayList<>();
for (BakeryMenuResponse eachBakeryMenuResponse: bakeryMenuResponseQueryResults.getResults()) {
content.add(new BakeryMenuResponse(eachBakeryMenuResponse));
}
return new PageImpl<>(content, pageable, bakeryMenuResponseQueryResults.getTotal());
}
[ 잘된 예 ]
groupBy를 사용한 경우 아래와 같이 명시적으로 counting이 필요합니다. 즉, 먼저 fetch()
를 통해 전체 결과를 뽑아낸 후 그 데이터들 중 필요한 부분만 응답으로 보내는 형식을 사용할 수 있습니다.
- 이 경우, 데이터가 많이 존재한다면 불필요한 데이터를 많이 가져오게 되어 성능상 좋지 않습니다. 하기 방법이 아닌 방안이 있는지는 좀 더 고민이 필요합니다.
public Page<BakeryMenuResponse> findBakeryMenuPageableByBakeryId(Long bakeryId, Pageable pageable) {
// offset과 limit을 설정하지 않고 모든 결과를 list에 담음
List<BakeryMenuResponse> bakeryMenuResponseList = jpaQueryFactory
.select(Projections.fields(BakeryMenuResponse.class,
menuReviews.menus.id.as("menuId"),
...
menuReviews.rating.avg().as("avgRating")))
.from(menuReviews)
.where(menuReviews.bakeries.id.eq(bakeryId))
.groupBy(menuReviews.menus.id, menuReviews.menus.breadCategories.id, menuReviews.menus.breadCategories.name, menuReviews.menus.name, menuReviews.menus.price, menuReviews.menus.imgPath)
.orderBy(menuReviews.id.count().desc(), menuReviews.rating.avg().desc())
.fetch();
List<BakeryMenuResponse> content = new ArrayList<>();
// list의 size를 얻어 totalElements와 totalPages에 사용
Integer total = bakeryMenuResponseList.size();
// list의 offset부터 limit까지 가져와야 하기 때문에 하기와 같이 구현
Integer limit = pageable.getPageSize() * (pageable.getPageNumber() + 1);
// 기존에 list로 가져온 응답 중, 필요한 offset ~ limit까지의 데이터만 담음
for (int i = pageable.getPageNumber(); i < limit && i < total; i ++) {
content.add(bakeryMenuResponseList.get(i));
}
return new PageImpl<>(content, pageable, total);
}
하기 postman을 통해 응답을 확인해보면, totalPages
와 totalElements
를 확인할 수 있습니다. 현재 1번 page를 호출했고, limit은 1로 두었기 때문에 content는 1개가 나왔으며, bakery 1번에 해당하는 메뉴는 4개만 존재하여 totalPages = 메뉴개수/1
, totalElements = 메뉴개수
로 나온 것을 확인할 수 있습니다.
Slice
Slice 방식의 경우, Page와 차이가 나는 부분은 total page 개수를 가져오지 않는다는 것입니다. 그저, 다음 데이터가 있는지 알기위해 처음에 데이터를 들고올 때, limit + 1
까지 들고오고 해당 데이터가 있다면 다음 데이터가 있다는 사실만 전달해줍니다.
해당 내용은 하기 소스 코드의 주석을 통해 확인할 수 있습니다.
public Slice<BakeryMenuResponse> findBakeryMenuPageableByBakeryId(Long bakeryId, Pageable pageable) {
List<BakeryMenuResponse> bakeryMenuResponseList = jpaQueryFactory
.select(Projections.fields(BakeryMenuResponse.class,
menuReviews.menus.id.as("menuId"),
...
menuReviews.rating.avg().as("avgRating")))
.from(menuReviews)
.where(menuReviews.bakeries.id.eq(bakeryId))
.groupBy(menuReviews.menus.id, menuReviews.menus.breadCategories.id, menuReviews.menus.breadCategories.name, menuReviews.menus.name, menuReviews.menus.price, menuReviews.menus.imgPath)
.orderBy(menuReviews.id.count().desc(), menuReviews.rating.avg().desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize() + 1) // limit보다 데이터를 1개 더 들고와서, 해당 데이터가 있다면 hasNext 변수에 true를 넣어 알림
.fetch();
List<BakeryMenuResponse> content = new ArrayList<>();
for (BakeryMenuResponse eachBakeryMenuResponse: bakeryMenuResponseList) {
content.add(new BakeryMenuResponse(eachBakeryMenuResponse));
}
boolean hasNext = false;
if (content.size() > pageable.getPageSize()) {
content.remove(pageable.getPageSize());
hasNext = true;
}
return new SliceImpl<>(content, pageable, hasNext);
}
앞서 Paging 방식을 통해 postman 호출을 했을 때와 달리, first
와 last
가 존재하여 hasNext
가 true
이면 last
가 false
로 뜨는 것을 확인해볼 수 있습니다.
마치며
필자의 경우, 앱 어플리케이션 프로젝트인터라 무한 스크롤 기능이 필요하여 Slice를 사용했습니다.
참고 자료
'FRAMEWORK > Spring' 카테고리의 다른 글
[SpringBoot] SpringBoot를 이용한 AWS S3에 resizing 이미지 업로드하기(Marvin 활용) (1) | 2021.12.04 |
---|---|
[SpringBoot] SpringBoot를 이용한 AWS S3에 여러 파일 업로드 및 삭제 구현하기 (2) | 2021.10.29 |
[Enum] enum에 연결된 값을 통해 enum값 알아내기 (0) | 2021.10.26 |
[Swagger 2] java.lang.NumberFormatException:For input string: "" exception 해결법 (0) | 2021.10.24 |
[Springboot, JWT] JWT를 이용한 소셜 로그인 구현하기 - Kakao/Google (0) | 2021.10.11 |