본문 바로가기
Spring Data

3-1. 페이징 성능 개선하기 - 검색 버튼 사용시 페이지 건수 고정하기

by 향로 (기억보단 기록을) 2020. 11. 1.
반응형

모든 코드는 Github에 있습니다.

앞서 포스팅에서 실질 페이징 쿼리 성능을 올리는 방법들을 소개 드렸는데요.

페이징 기능을 구현하는데 있어, 페이징 쿼리 자체를 개선하는 것도 방법이지만 그 외 다른 기능을 개선하는 방법도 함께할 수 있습니다.

여기서 말하는 그 외 기능은 바로 count 쿼리입니다.
일반적인 페이징 기능에 있어 데이터 조회와 함께 매번 함께 수행되는 것이 바로 count 쿼리인데요.
해당 조건으로 조회되는 총 건수를 알아야만 아래와 같이 pageNo들을 노출시킬 수 있기 때문입니다.
(총 건수 / pageSize)

count_no

당연히 No Offset을 사용한다면 사용되지 않는 쿼리입니다.

여기서 count 쿼리에 대해 크게 생각하지 않으시는 분들도 계시는데요.
(조회 건수에 따라 차이가 나지만) 실제 데이터 조회만큼 오래 걸리기도 합니다.

이유는 총 몇건인지 확인하기 위해 전체를 확인해야하기 때문입니다.

데이터 조회는 limit 10 등으로 지정된 사이즈만큼 읽고 나서는 더이상 읽지 않아도 되지만, count는 끝까지 읽어서 몇 건인지 확인해야하기 때문에 페이징 쿼리의 성능 이슈 중 하나가 됩니다.

legacy_time

(어떤 조회 환경에서는 count 쿼리만 10초가 걸리기도 합니다.)

simple_query

(이렇게 단순한 쿼리도 1억건 이상일 경우 1초 이상 수행되기도 합니다.)

그래서 이 문제를 개선할 수 있는 방법은 크게 2가지가 있습니다.

  1. 검색 버튼 사용시 페이지 건수 고정하기
  2. 첫 페이지 조회 결과 cache 하기

하나씩 알아보겠습니다.

3-1. 검색 버튼 사용시 페이지 건수 고정하기

구글 검색을 이용해보신 분들은 간혹 경험하실텐데요.
처음 검색 버튼을 클릭 했을때는 6페이지 혹은 10페이지로 보던 검색 결과가

google_before

실제 페이지 버튼을 클릭해서 넘어가면 페이지 결과가 줄어드는 것을 볼 수 있는데요.

google_after

여기에서 컨셉을 참고할 수 있습니다.

굳이 사용율이 떨어지는 페이지 버튼을 위해 매번 전체 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개 페이지를 고정으로 노출하도록 개선하기 위해서는 다음의 코드가 추가되어야 하는데요.

  1. 검색 버튼 클릭한 경우(useSearchBtn)에는 10개 페이지가 노출되도록 TotalCount (fixedPageCount) 를 반환한다.
  2. 페이지 버튼을 클릭한 경우 실제 쿼리를 수행해 결과를 반환한다
  3. 페이지 버튼을 클릭하였지만, 전체 건수를 초과한 페이지 번호로 요청이 온 경우에는 마지막 페이지 결과를 반환한다.

마지막 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

바로 테스트를 돌려보면?

dto-test-result

정상적으로 통과하는 것을 확인할 수 있습니다.

자 그럼 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)
}

search_btn_result

실제 건수가 리턴되는 케이스

@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);
}

page_btn_result

Repository의 테스트 코드 역시 정상적으로 수행 되는 것을 확인할 수 있습니다.

3-1-3. 결론

실제 제가 진행했던 몇몇 프로젝트에서는 검색 버튼을 클릭하는 경우가 검색의 80%를 넘기도 했는데요.
페이지 버튼을 클릭하는 경우가 전체 검색에서 20%도 안되는 상황에서 매번 10초대의 쿼리가 수행되는 것은 부담스러운 일입니다.
이번 방법은 이런 경우에 많은 효과를 볼 수 있습니다.

단, UX상으로 동적인 페이지 사이즈가 변경되는 것이 팀이나 회사의 입장에서 원하지 않는다면 사용할 수 없으니 꼭 협의 후에 적용하시는 걸 추천드립니다.

다음 글에서는 이와는 반대로 페이지 번호를 통한 조회가 많을 경우에 대해서 다뤄보겠습니다.


반응형