모든 코드는 Github에 있습니다.
앞서 포스팅에서 실질 페이징 쿼리 성능을 올리는 방법들을 소개 드렸는데요.
페이징 기능을 구현하는데 있어, 페이징 쿼리 자체를 개선하는 것도 방법이지만 그 외 다른 기능을 개선하는 방법도 함께할 수 있습니다.
여기서 말하는 그 외 기능은 바로 count 쿼리입니다.
일반적인 페이징 기능에 있어 데이터 조회와 함께 매번 함께 수행되는 것이 바로 count 쿼리인데요.
해당 조건으로 조회되는 총 건수를 알아야만 아래와 같이 pageNo들을 노출시킬 수 있기 때문입니다.
(총 건수 / pageSize)
당연히 No Offset을 사용한다면 사용되지 않는 쿼리입니다.
여기서 count 쿼리에 대해 크게 생각하지 않으시는 분들도 계시는데요.
(조회 건수에 따라 차이가 나지만) 실제 데이터 조회만큼 오래 걸리기도 합니다.
이유는 총 몇건인지 확인하기 위해 전체를 확인해야하기 때문입니다.
데이터 조회는 limit 10
등으로 지정된 사이즈만큼 읽고 나서는 더이상 읽지 않아도 되지만, count
는 끝까지 읽어서 몇 건인지 확인해야하기 때문에 페이징 쿼리의 성능 이슈 중 하나가 됩니다.
(어떤 조회 환경에서는 count 쿼리만 10초가 걸리기도 합니다.)
(이렇게 단순한 쿼리도 1억건 이상일 경우 1초 이상 수행되기도 합니다.)
그래서 이 문제를 개선할 수 있는 방법은 크게 2가지가 있습니다.
- 검색 버튼 사용시 페이지 건수 고정하기
- 첫 페이지 조회 결과 cache 하기
하나씩 알아보겠습니다.
3-1. 검색 버튼 사용시 페이지 건수 고정하기
구글 검색을 이용해보신 분들은 간혹 경험하실텐데요.
처음 검색 버튼을 클릭 했을때는 6페이지 혹은 10페이지로 보던 검색 결과가
실제 페이지 버튼을 클릭해서 넘어가면 페이지 결과가 줄어드는 것을 볼 수 있는데요.
여기에서 컨셉을 참고할 수 있습니다.
굳이 사용율이 떨어지는 페이지 버튼을 위해 매번 전체 count 쿼리가 수행될 필요가 있을까를 한번 고민해볼 필요가 있는데요.
실제로 구글의 검색 페이지 결과가 어떻게 구현되어있는지는 알 수 없기 때문에 컨셉만 참고했다는 것을 말씀드립니다.
구글 같이 엄청나게 방대한 데이터를 적재해서 제공하는 서비스에서 이렇게 단순한 RDBMS 를 사용하진 않을테니 "구글이 이렇게 하더라" 라고 생각하시면 안됩니다.
즉, 다음과 같은 상황에서 이 방법을 고려해보시면 좋습니다.
- 대부분의 조회 요청이 검색 버튼 클릭 (즉, 첫 조회)에서 발생하고
- 페이지 버튼을 통한 조회 요청이 소수일 경우
이럴 경우 검색 버튼을 클릭한 경우만 Page 수를 고정하는 것 입니다.
즉, 다음 페이지로 이동하기 위해 페이지 버튼을 클릭했을때만 실제 페이지 count 쿼리를 발생시켜 정확한 페이지수를 사용하고, 대부분의 요청이 발생하는 검색 버튼 클릭시에는 count 쿼리를 발생시키지 않는 것 입니다.
자 그럼 실제 코드를 한번 보겠습니다.
3-1-1. 구현 코드
먼저 기존 페이징 쿼리는 아래와 같습니다.
public Page<BookPaginationDto> paginationCount(Pageable pageable, String name) {
JPQLQuery<BookPaginationDto> query = querydsl().applyPagination(pageable,
queryFactory
.select(Projections.fields(BookPaginationDto.class,
book.id.as("bookId"),
book.name,
book.bookNo,
book.bookType
))
.from(book)
.where(
book.name.like(name + "%")
)
.orderBy(book.id.desc()));
List<BookPaginationDto> items = query.fetch(); // 데이터 조회
long totalCount = query.fetchCount(); // 전체 count
return new PageImpl<>(items, pageable, totalCount);
}
private Querydsl querydsl() {
return Objects.requireNonNull(getQuerydsl());
}
이 코드를 검색 버튼 클릭시에는 10개 페이지를 고정으로 노출하도록 개선하기 위해서는 다음의 코드가 추가되어야 하는데요.
- 검색 버튼 클릭한 경우(
useSearchBtn
)에는 10개 페이지가 노출되도록 TotalCount (fixedPageCount
) 를 반환한다. - 페이지 버튼을 클릭한 경우 실제 쿼리를 수행해 결과를 반환한다
- 페이지 버튼을 클릭하였지만, 전체 건수를 초과한 페이지 번호로 요청이 온 경우에는 마지막 페이지 결과를 반환한다.
마지막 3번이 조금 복잡한 로직인데,
이런 경우가 발생하는 이유는 다음과 같습니다.
- 1번으로 인해서 노출된 페이지 번호는 10개
- 실제 전체 건수와 무방하게 강제로 10개 페이지를 노출시켰기 때문에 사용자는 언제든 10번째 페이지 번호를 클릭할 수 있음
- 10번째 페이지를 클릭했는데, 막상 전체 데이터가 그만큼 안된다면 (ex: 전체 건수가 70개라면 pageSize=10 라서 실제 전체 페이지 수가 7개밖에 안되는 경우) 노출할 데이터가 없습니다.
자 그래서 이들을 다 적용하게 되면 다음의 코드가 됩니다.
public Page<BookPaginationDto> paginationCountSearchBtn(boolean useSearchBtn, Pageable pageable, String name) {
JPAQuery<BookPaginationDto> query = queryFactory
.select(Projections.fields(BookPaginationDto.class,
book.id.as("bookId"),
book.name,
book.bookNo,
book.bookType
))
.from(book)
.where(
book.name.like(name + "%")
)
.orderBy(book.id.desc());
JPQLQuery<BookPaginationDto> pagination = querydsl().applyPagination(pageable, query);
if(useSearchBtn) { // 검색 버튼 사용시
int fixedPageCount = 10 * pageable.getPageSize(); // 10개 페이지 고정
return new PageImpl<>(pagination.fetch(), pageable, fixedPageCount);
}
long totalCount = pagination.fetchCount();
Pageable pageRequest = exchangePageRequest(pageable, totalCount); // 데이터 건수를 초과한 페이지 버튼 클릭시 보정
return new PageImpl<>(querydsl().applyPagination(pageRequest, query).fetch(), pageRequest, totalCount);
}
Pageable exchangePageRequest(Pageable pageable, long totalCount) {
/**
* 요청한 페이지 번호가 기존 데이터 사이즈를 초과할 경우
* 마지막 페이지의 데이터를 반환한다
*/
int pageNo = pageable.getPageNumber();
int pageSize = pageable.getPageSize();
long requestCount = (pageNo - 1) * pageSize; // pageNo:10, pageSize:10 일 경우 requestCount=90
if (totalCount > requestCount) { // 실제 전체 건수가 더 많은 경우엔 그대로 반환
return pageable;
}
int requestPageNo = (int) Math.ceil((double)totalCount/pageNo); // ex: 71~79이면 8이 되기 위해
return PageRequest.of(requestPageNo, pageSize);
}
여기서 exchangePageRequest()
메소드를 좀 더 객체지향적으로 분리하기 위해 별도의 Dto 클래스로 추출할 수도 있습니다.
public class FixedPageRequest extends PageRequest {
protected FixedPageRequest(Pageable pageable, long totalCount) {
super(getPageNo(pageable, totalCount), pageable.getPageSize(), pageable.getSort());
}
private static int getPageNo(Pageable pageable, long totalCount) {
int pageNo = pageable.getPageNumber();
int pageSize = pageable.getPageSize();
long requestCount = pageNo * pageSize; // pageNo:10, pageSize:10 일 경우 requestCount=90
if (totalCount > requestCount) { // 실제 건수가 요청한 페이지 번호보다 높을 경우
return pageNo;
}
return (int) Math.ceil((double)totalCount/pageNo); // 실제 건수가 부족한 경우 요청 페이지 번호를 가장 높은 번호로 교체
}
}
이렇게 할 경우 Repository는 다음처럼 개선됩니다.
public Page<BookPaginationDto> paginationCountSearchBtn2(boolean useSearchBtn, Pageable pageable, String name) {
JPAQuery<BookPaginationDto> query = queryFactory
.select(Projections.fields(BookPaginationDto.class,
book.id.as("bookId"),
book.name,
book.bookNo,
book.bookType
))
.from(book)
.where(
book.name.like(name + "%")
)
.orderBy(book.id.desc());
JPQLQuery<BookPaginationDto> pagination = querydsl().applyPagination(pageable, query);
if(useSearchBtn) {
int fixedPageCount = 10 * pageable.getPageSize(); // 10개 페이지 고정
return new PageImpl<>(pagination.fetch(), pageable, fixedPageCount);
}
long totalCount = pagination.fetchCount();
Pageable pageRequest = new FixedPageRequest(pageable, totalCount);
return new PageImpl<>(querydsl().applyPagination(pageRequest, query).fetch(), pageRequest, totalCount);
}
자 이렇게 됨으로써 "3. 전체 건수를 초과한 페이지 요청에는 마지막 페이지 결과 반환"에 대해서는 FixedPageRequest
클래스가 담당하게 되었으니 테스트 코드 역시 별도로 진행할 수 있게 되었습니다.
그럼 바로 테스트 코드를 보겠습니다.
3-1-2. 테스트 코드
먼저 테스트 해볼 것은 FixedPageRequest
클래스 입니다.
앞서 설명 드린것처럼 FixedPageRequest
는 "전체 건수를 초과한 페이지 번호 요청에는 마지막 페이지 요청"으로 변환하는 역할을 합니다.
@ParameterizedTest
@CsvSource({
"10, 100, 10", // (1)
"10, 101, 10", // (2)
"10, 91, 10", // (3)
"10, 90, 9", // (4)
"10, 79, 8"}) // (5)
void dto_exchange_page_request(int pageNo, long totalCount, int expectedPageNo) throws Exception {
//given
Pageable pageRequest = PageRequest.of(pageNo, 10);
//when
Pageable result = new FixedPageRequest(pageRequest, totalCount);
//then
assertThat(result.getPageNumber()).isEqualTo(expectedPageNo);
}
검증 케이스는 다음과 같습니다.
(1) 페이지번호:10 / 전체 건수: 100 / 변환 후 받은 페이지 번호: 10
(2) 페이지번호:10 / 전체 건수: 101 / 변환 후 받은 페이지 번호: 10
(3) 페이지번호:10 / 전체 건수: 91 / 변환 후 받은 페이지 번호: 10
(4) 페이지번호:10 / 전체 건수: 90 / 변환 후 받은 페이지 번호: 9
(5) 페이지번호:10 / 전체 건수: 79 / 변환 후 받은 페이지 번호: 8
바로 테스트를 돌려보면?
정상적으로 통과하는 것을 확인할 수 있습니다.
자 그럼 Repository 테스트를 해볼텐데요.
Repository 테스트는 2개로 나뉩니다.
실제 건수와 무관하게 10개 페이지의 개수가 리턴되는 케이스
@Test
void 검색버튼사용시_10개_페이지_건수가_리턴된다() throws Exception {
PageRequest pageRequest = PageRequest.of(1, 10);
boolean useSearchBtn = true;
Page<BookPaginationDto> page = bookPaginationRepositorySupport.paginationCountSearchBtn(useSearchBtn, pageRequest, prefixName);
//then
assertThat(page.getTotalElements()).isEqualTo(100); // 10 (pageCount) * 10 (pageSize)
}
실제 건수가 리턴되는 케이스
@Test
void 페이지버튼사용시_실제_페이지_건수가_리턴된다() throws Exception {
PageRequest pageRequest = PageRequest.of(1, 10);
boolean useSearchBtn = false;
Page<BookPaginationDto> page = bookPaginationRepositorySupport.paginationCountSearchBtn(useSearchBtn, pageRequest, prefixName);
//then
assertThat(page.getTotalElements()).isEqualTo(30);
}
Repository의 테스트 코드 역시 정상적으로 수행 되는 것을 확인할 수 있습니다.
3-1-3. 결론
실제 제가 진행했던 몇몇 프로젝트에서는 검색 버튼을 클릭하는 경우가 검색의 80%를 넘기도 했는데요.
페이지 버튼을 클릭하는 경우가 전체 검색에서 20%도 안되는 상황에서 매번 10초대의 쿼리가 수행되는 것은 부담스러운 일입니다.
이번 방법은 이런 경우에 많은 효과를 볼 수 있습니다.
단, UX상으로 동적인 페이지 사이즈가 변경되는 것이 팀이나 회사의 입장에서 원하지 않는다면 사용할 수 없으니 꼭 협의 후에 적용하시는 걸 추천드립니다.
다음 글에서는 이와는 반대로 페이지 번호를 통한 조회가 많을 경우에 대해서 다뤄보겠습니다.