Spring Data

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

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

모든 코드는 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상으로 동적인 페이지 사이즈가 변경되는 것이 팀이나 회사의 입장에서 원하지 않는다면 사용할 수 없으니 꼭 협의 후에 적용하시는 걸 추천드립니다.

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


반응형