Spring/Issue

QueryDsl을 이용한 페이지네이션 성능 개선

블로그 주인장 2024. 1. 27.

Issue

  • 아래의 코드는 페이지네이션 을 구현한 예시이다.
  • 아래의 코드를 직역하면 채팅방(Chatroom) 를 fetch join 하여 채팅방 참여자(ChatParticipant)를 생성 날짜를 최신 기준으로 페이징 처리한 것을 나타낸 것이다.
  • 이를 바탕으로 @Query 어노테이션을 사용하는 JPQL 이 아닌 객체 지향 쿼리 중 하나인 QueryDsl 을 이용하여 변환을 진행하려고 한다.
@Query("SELECT cp FROM ChatParticipant cp "
      + " JOIN FETCH cp.chatRoom cr "
      + " WHERE cp.member.id = :memberId"
      + " ORDER BY cr.createdAt DESC ")
  Page<ChatParticipant> findAllByMemberId(@Param("memberId") Long memberId, Pageable pageable);

 

Problem

페이지네이션을 QueryDsl 을 이용하여 구현하는 방법은 2가지가 존재한다.

  1. PageImpl
    return new PageImpl<>(list, pageable, count);​
  2. PageableExecutionUtils
    return PageableExecutionUtils.getPage(list, pageable, countQuery::fetchOne);​

 

2가지의 기능의 차이를 확인하여 페이지네이션 처리 시에 최적화를 진행해보겠습니다.

 

PageImpl 방식은 totalCount 를 구하는 쿼리를 무조건 적으로 실행하는 방식이지만, 

PageableExecutionUtils.getPage() 방식은 필요한 경우에만 totalCount를 실행할 수 있다.

 

예시로, totalCount 쿼리 실행이 필요 없는 경우는 생략해서 처리를 하게 된다.

  • 페이지의 시작이면서 컨텐츠 사이즈가 페이즈 사이즈보다 작을 경우
  • 마지막 페이지인 경우(offset + 컨텐츠 사이즈를 더해서 전체 사이즈를 구한다)

 

QueryDsl의 페이징 처리

보통의 QueryDsl 에서 페이징 처리 시에는 new PageImpl()을 사용했었습니다.

  • Content : 페이징을 적용한 쿼리의 결과를 뜻한다.
  • total : 페이징을 적용하지 않은 전체 결과의 크기를 뜻한다.

PageImpl + total Count 실행

  1. content
    1. JpaQuery의 fetch() 혹은 fetchResults() 결과 값을 의미합니다.
    2. fetchResults() 는 Deprecated 되어 있는 상태라서 fetch()를 사용하는 것을 권장한다.
  2. total Count(size)
    1. total 에는 count 값이 들어간다.
    2. 위의 content 쿼리와 동일한 where 절을 적용하지만, offset limit을 적용하지 않은 결과의 수를 출력하게 됩니다.
@Override
  public Page<ChatParticipant> findAllMyChatting(Long memberId, Pageable pageable) {
	 /**
	   * Content
	   */
    List<ChatParticipant> list = jpaQueryFactory.selectFrom(chatParticipant)
        .innerJoin(chatParticipant.chatRoom, chatRoom)
        .fetchJoin()
        .where(chatParticipant.member.id.eq(memberId))
        .orderBy(chatParticipant.chatRoom.userCount.desc())
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();

	 /**
	   * size
	   */
    int size = jpaQueryFactory.select(chatParticipant.count())
        .from(chatParticipant)
        .where(chatParticipant.member.id.eq(memberId))
        .fetch().size();

    return new PageImpl<>(list, pageable, size);
  }

 

이를 통해 정리를 하자면

new PageImpl() 은 총 2번의 쿼리를 실행하여 페이징을 적용하는 것에 대해 알 수 있습니다.

 

Solve

new PageImpl()count 쿼리를 개선한

PageableExecutionUtils를 이용해서 QueryDsl을 이용한 페이징을 이용하여 문제를 해결해보겠습니다.

 

PageExecutionUtils

공식문서를 바탕으로 PageableExecutionUtils 를 알아보겠습니다.

 

아래 밑줄을 의역하면

데이터 쿼리가 count 쿼리보다 비용이 절감되기에 일부 경우 최적화를 활용할 수 있다고 가정합니다.

 

정리하면 PageableExecutionUtils을 이용하면 단순 new PageImpl()을 사용할 때보다

성능 최적화에 이점이 있다는 것을 의미한다.

 

PageableExecutionUtils 클래스에는 getPage() 라는 단 하나의 메서드를 가집니다.

public static <T> Page<T> getPage(List<T> content, Pageable pageable, LongSupplier totalSupplier) {
    Assert.notNull(content, "Content must not be null");
    Assert.notNull(pageable, "Pageable must not be null");
    Assert.notNull(totalSupplier, "TotalSupplier must not be null");
    if (!pageable.isUnpaged() && pageable.getOffset() != 0L) {
      return content.size() != 0 && pageable.getPageSize() > content.size()
      ? new PageImpl(content, pageable, pageable.getOffset() + (long)content.size())
      : new PageImpl(content, pageable, totalSupplier.getAsLong());
    } else {
      return !pageable.isUnpaged() && pageable.getPageSize() <= content.size()
      ? new PageImpl(content, pageable, totalSupplier.getAsLong())
      : new PageImpl(content, pageable, (long)content.size());
    }
  }

 

위의 조건을 정리해보자면

  1. 첫번째 페이지(pageable.getOffset() != 0L)가 아니면서 content의 크기가 한 페이지의 사이즈보다 작을 경우(pageable.getPageSize() > content.size())
  2. 첫 번째 페이지이면서 content 크기가 한 페이지의 사이즈보다 작을 때 (ex, content:3개, page 크기: 10)
  • totalSupplier.getAsLong() 대신 Content의 size와 offset 값으로 total 값을 대신합니다.

이를 확인하면 PageExecutionUtils.getPage() 내부에서는 new PageImpl()을 호출하고 있습니다.

즉, new PageImpl()을 한번 더 추상화했다고 볼 수 있습니다.

 

new PageImpl()와 getPage()의 차이는 세 번째 인자만 LongSupplier totalSupplier 로 변경되었다는 점이다.

함수형 인터페이스가 대신하는데, 이를 바탕으로 count 쿼리의 호출 시점을 지연 시킬 수 있습니다.

 

pageImpl()에선 count 값을 세 번째 인자로 넣기 위해 countQuery 쿼리를 필수로 호출했지만

PageableExecutionUtils.getPage() 에선 count 쿼리를 실행하기 전인 함수형 인터페이스를 인자로 받고 있습니다.

 

new PageImpl()의 세 번째 인자로 totalSupplier 대신 content.size() , pageable.getOffset() 값을 받습니다.

즉 count 쿼리를 실행하지 않아 불필요한 쿼리를 날리지 않습니다.

기존 pageImpl에서 두 번의 쿼리를 날리던 것에 비해 성능 최적화를 이룰 수 있습니다.

 

PageableExecutionUtils.getPage 실행

@Override
  public Page<ChatParticipant> findAllMyChattingRoom(Long memberId, Pageable pageable) {
    List<ChatParticipant> list = jpaQueryFactory.selectFrom(chatParticipant)
        .innerJoin(chatParticipant.chatRoom, chatRoom)
        .fetchJoin()
        .where(chatParticipant.member.id.eq(memberId))
        .orderBy(chatParticipant.chatRoom.userCount.desc())
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();

    JPAQuery<Long> countQuery = jpaQueryFactory.select(chatParticipant.count())
        .from(chatParticipant)
        .where(chatParticipant.member.id.eq(memberId));

    return PageableExecutionUtils.getPage(list, pageable, countQuery::fetchOne);
  }

 

해당 코드는 현재 진행하고 프로젝트에서 가져온 예시입니다.

 

여기서 중점으로 봐야할 부분은 PageableExecutionUtils.getPage() 3번째 인자입니다.

() -> countQuery.fetchOne(countQuery::fetchOne) 람다식을 인자로 받고 있습니다.

 

즉, getPage() 메서드 내부에서 해당 람다식을 호출하기 전까지 count 쿼리는 실행되지 않습니다.

이를 바탕으로 테스트를 진행해보겠습니다.

 

Test case #1 페이지 사이즈 5 / content 3개 / 첫 번째 페이지 호출

  • API 호출 : GET <http://localhost:8080/chatRoom/members/{memberId}?page=0&size=5>

시작 페이지(number : 0) & 페이지 사이즈(size)보다 현재 content.size가 작은 경우입니다.

이 경우에는 함수형 인터페이스인 totalSupplier 가 실행되지 않습니다.

즉, count 쿼리를 진행하지 않는다는 것을 의미합니다.

 

 

해당 쿼리 실행한 것을 보면 조회 쿼리만 실행되고 count 쿼리는 실행되지 않는 것을 확인할 수 있습니다.

 

Test case #2 페이지 사이즈 1 / content 3개 / 두 번째 페이지 호출

  • API 호출 : GET <http://localhost:8080/chatRoom/members/{memberId}?page=1&size=1>
  • 맨 처음과 끝이 아닌 중간 페이지를 조회해보겠습니다.

해당 쿼리문을 살펴보겠습니다.

 

해당 쿼리를 확인해보면 카운트 쿼리도 동시에 실행되는 것을 확인할 수 있습니다.

 

즉, 첫번째 페이지(pageable.getOffset() != 0L)가 아니면서

content의 크기가 한 페이지의 사이즈보다 큰 경우(content : 3 > size : 1)에 해당하지 않기 때문에

totalSupplier.getAsLong() 함수형 인터페이스가 동작해서 count 쿼리를 진행한 걸로 판단할 수 있습니다.

 

 

마무리

이를 통해 PageableExecutionUtils.getPage() 를 사용하면 불필요한 count 쿼리가 실행되는 케이스를 줄일 수 있음을 알 수 있습니다. 즉, content 크기가 한 페이지의 사이즈보다 작을 때는 불필요한 count 쿼리가 진행되지 않음을 알 수 있었습니다.

사용자가 첫 페이지에서 머무르는 경우가 많은 서비스라면 해당 기능을 이용하여 성능 최적화가 제대로 이루어질 것이라고 판단됩니다.

 

 

본 포스트는 작성자가 공부한 내용을 바탕으로 작성한 글입니다.
잘못된 내용이 있을 시 언제든 댓글로 피드백 부탁드리겠습니다.
항상 정확한 내용을 포스팅하도록 노력하겠습니다.

반응형

댓글