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가지가 존재한다.
- PageImpl
return new PageImpl<>(list, pageable, count);
- 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 실행
- content
- JpaQuery의 fetch() 혹은 fetchResults() 결과 값을 의미합니다.
- fetchResults() 는 Deprecated 되어 있는 상태라서 fetch()를 사용하는 것을 권장한다.
- total Count(size)
- total 에는 count 값이 들어간다.
- 위의 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());
}
}
위의 조건을 정리해보자면
- 첫번째 페이지(pageable.getOffset() != 0L)가 아니면서 content의 크기가 한 페이지의 사이즈보다 작을 경우(pageable.getPageSize() > content.size())
- 첫 번째 페이지이면서 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 쿼리가 진행되지 않음을 알 수 있었습니다.
사용자가 첫 페이지에서 머무르는 경우가 많은 서비스라면 해당 기능을 이용하여 성능 최적화가 제대로 이루어질 것이라고 판단됩니다.
본 포스트는 작성자가 공부한 내용을 바탕으로 작성한 글입니다.
잘못된 내용이 있을 시 언제든 댓글로 피드백 부탁드리겠습니다.
항상 정확한 내용을 포스팅하도록 노력하겠습니다.
'Spring > Issue' 카테고리의 다른 글
STOMP와 RabbitMQ를 이용한 채팅 서비스 (0) | 2024.01.30 |
---|---|
[JPA] fetch-Join을 이용한 N+1 문제 해결 (1) | 2024.01.26 |
Stomp를 활용한 웹소켓 구현 (1) | 2024.01.24 |
댓글