Blog

Querydsl 페이징: PageImpl과 PageableExecutionUtils의 차이

아래 두 코드는 모두 페이징 결과를 반환합니다. 그런데 전체 개수를 구하는 방식에는 어떤 차이가 있을까요?

Java
return PageableExecutionUtils.getPage(result, pageable, countQuery::fetchOne);
Java
return new PageImpl<>(result, pageable, count);

마지막 페이지라면 개수를 추론할 수 있습니다

전체 데이터가 24개이고 페이지 크기가 10이라고 가정했습니다. 첫째와 둘째 페이지에는 각각 10개가 들어가서 결과가 페이지를 꽉 채웁니다. 그래서 이 두 페이지 결과만으로는 전체 개수가 나오지 않습니다.

셋째 페이지에는 4개가 들어갑니다. 앞의 20개와 현재 4개를 더하면 전체 24개가 나오므로 이런 경우 PageableExecutionUtils는 전달받은 count 함수를 호출하지 않아도 됩니다.

Spring Data의 PageableExecutionUtils API도 페이지 정보와 결과 크기로 전체 개수를 판단할 수 있을 때 count를 생략한다고 설명합니다. 첫 페이지라는 이유만으로 count가 생략되지는 않습니다.

당시 남긴 쿼리 로그

아래 로그에는 count까지 포함해 쿼리 세 번이 기록되어 있습니다. 내용 조회 두 번과 count 한 번입니다.

SQL
select
        answer.member.nickname as writer,
        answer.content as content,
        answer.auditEntity.createdAt as createdAt 
    from
        Answer answer 
    where
        answer.question.id = ?1 */ select
            m1_0.nickname,
            a1_0.content,
            a1_0.created_at 
        from
            answer a1_0 
        join
            member m1_0 
                on m1_0.id=a1_0.member_id 
        where
            a1_0.question_id=? 
        offset
            ? rows 
        fetch
            first ? rows only

select
        answer.member.nickname as writer,
        answer.content as content,
        answer.auditEntity.createdAt as createdAt 
    from
        Answer answer 
    where
        answer.question.id = ?1 */ select
            m1_0.nickname,
            a1_0.content,
            a1_0.created_at 
        from
            answer a1_0 
        join
            member m1_0 
                on m1_0.id=a1_0.member_id 
        where
            a1_0.question_id=? 
        offset
            ? rows 
        fetch
            first ? rows only
            
select
        count(answer) 
    from
        Answer answer 
    where
        answer.question.id = ?1 */ select
            count(a1_0.id) 
        from
            answer a1_0 
        where
            a1_0.question_id=?

다음 로그에서는 내용 조회 두 번만 남고 count가 생략됐습니다. 이 로그의 횟수는 이번 호출에서 나온 값이라 다른 페이징 호출에서는 달라지기도 합니다.

SQL
select
        answer.member.nickname as writer,
        answer.content as content,
        answer.auditEntity.createdAt as createdAt 
    from
        Answer answer 
    where
        answer.question.id = ?1 */ select
            m1_0.nickname,
            a1_0.content,
            a1_0.created_at 
        from
            answer a1_0 
        join
            member m1_0 
                on m1_0.id=a1_0.member_id 
        where
            a1_0.question_id=? 
        offset
            ? rows 
        fetch
            first ? rows only

select
        answer.member.nickname as writer,
        answer.content as content,
        answer.auditEntity.createdAt as createdAt 
    from
        Answer answer 
    where
        answer.question.id = ?1 */ select
            m1_0.nickname,
            a1_0.content,
            a1_0.created_at 
        from
            answer a1_0 
        join
            member m1_0 
                on m1_0.id=a1_0.member_id 
        where
            a1_0.question_id=? 
        offset
            ? rows 
        fetch
            first ? rows only

생성자와 지연 실행의 차이

당시 확인한 PageImpl 생성자는 이렇습니다.

Java
public PageImpl(List<T> content, Pageable pageable, long total) {  
  
	super(content, pageable);  
	  
	this.total = pageable.toOptional().filter(it -> !content.isEmpty())//  
				.filter(it -> it.getOffset() + it.getPageSize() > total)//  
				.map(it -> it.getOffset() + content.size())//  
				.orElse(total);  
}

total은 이미 계산된 long 값입니다. 당시에는 “PageImpl은 항상 count 쿼리를 실행한다”고 생각했지만 생성자는 쿼리를 실행하지 않고 전달된 값을 사용합니다. 글머리에서는 이 차이를 두고 두 코드를 비교했습니다. 하나는 count를 미리 실행해 인자로 전달하고 다른 하나는 supplier로 실행을 미룹니다. 다음 getPage()는 LongSupplier를 받아 필요한 분기에서만 전체 개수를 구합니다.

Java
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() == 0) {  
		  
		if (pageable.isUnpaged() || pageable.getPageSize() > content.size()) {  
			return new PageImpl<>(content, pageable, content.size());  
			}  
		  
		return new PageImpl<>(content, pageable, totalSupplier.getAsLong());  
	}  
	  
	if (content.size() != 0 && pageable.getPageSize() > content.size()) {  
		return new PageImpl<>(content, pageable, pageable.getOffset() + content.size());  
	}  
	  
	return new PageImpl<>(content, pageable, totalSupplier.getAsLong());  
}

위 코드의 분기를 풀어 쓰면 이렇습니다.

  • 페이징하지 않는 요청은 현재 결과 크기를 전체 개수로 사용합니다.
  • 첫 페이지에서 결과가 페이지 크기보다 적으면 현재 결과 크기를 사용합니다.
  • 첫 페이지가 아니면서 결과가 비어 있지 않고 페이지 크기보다 적으면 offset + content.size()를 사용합니다.
  • 나머지는 totalSupplier.getAsLong()을 호출합니다.

마지막 페이지라도 페이지 크기만큼 꽉 차 있으면 count를 생략할 수 없습니다. 첫 페이지가 아닌 빈 페이지도 위 코드에서는 supplier를 호출합니다.