✅ 개요
Querydsl은 페이징 처리를 위해 `PageImpl()`의 최적화 버전인 `PageableExecutionUtils` 클래스의 정적 메서드 `getPage()`를 지원합니다. ` PageableExecutionUtils.getPage()`는 어떻게 페이징 쿼리를 최적화하는지 분석하고 정리하려고 합니다.
✅ PageableExecutionUtils
먼저 `PageableExecutionUtils` 클래스 내부 코드를 살펴보았습니다.

내부적으로 엄청나게 복잡한 로직으로 이루어져 있지는 않아서 다행이었습니다. 하나씩 이해하면 원리를 완벽히 이해하기에 큰 어려움은 없을 것 같습니다.
➡️ isPartialPage
`getPage` 메서드는 먼저 `isPartialPage`인지 확인하고 있습니다. 여기서 false를 반환하면 바로 카운트 쿼리를 실행하러 갑니다.

조건은 간단했습니다. 조회하려는 페이지 크기가 조회된 데이터의 개수보다 큰지 비교하고 있습니다.
예를 들어 `pageSize=10`으로 조회했을 때 조회된 데이터(`content`)가 7개이면 이는 마지막 페이지일 것입니다.
즉 조회하려는 페이지 크기가 조회된 데이터의 개수보다 크다는 것은 마지막 페이지 또는 결과가 없다고 볼 수 있고,
반대로 조회된 데이터가 같거나 많은 경우는 중간 페이지 일 것이기 때문에 다음 페이지가 있을 수 있다고 보고 카운트 쿼리를 실행해 전체 페이지 수를 계산해야 합니다.
➡️ isFirstPage
`isFirstPage`는 offset이 0인지, 즉 첫 페이지인지 확인하고 있습니다.

바깥 if문 `isPartialPage` 조건과 결합하여 보면 첫 페이지이면서 결과 개수가 pageSize보다 적은 경우를 의미합니다. 예를 들어 `page=0`, `size=10`으로 조회했을 때 결과가 6개이면, 이미 전체 데이터가 한 페이지 안에 다 존재한다라고 이해할 수 있습니다.
이 경우 조회된 데이터 개수가 곧 `total`을 의미하기 때문에 굳이 카운트 쿼리를 실행하지 않고 `content.size()`를 전달하는 것입니다.
➡️ !content.isEmpty()

이 조건은 이번 페이지 결과 개수가 pageSize보다 작고(`isPartialPage`), 첫 페이지는 아닐 때(`isFirstPage`) 실행됩니다. 이때 결과가 하나라도 있다면 카운트 쿼리 없이 반환합니다.
예를 들어 `page=2`, `size=10`으로 조회했을 때 결과가 5개이면, 2페이지를 마지막 페이지라고 볼 수 있습니다. 즉 카운트 쿼리 필요 없이 현재까지의 offset에 남은 데이터 개수를 더하면 `total`을 계산할 수 있는 것입니다.
➡️ 그 외

그 외의 경우는 모두 추가 카운트 쿼리를 실행합니다. 즉 size만큼 데이터가 조회됐으며 마지막 페이지인지도 알 수 없다면 전체 개수를 계산으로 알 수가 없기 때문에 카운트 쿼리가 필요하게 됩니다.
➕ PageImpl 생성자
`PageImpl`의 생성자도 살펴보았는데 특이한 부분이 있었습니다. 전달받은 `total`을 그대로 사용하지 않고 무언가 추가적인 계산을 하는 것입니다.

Javadoc 설명을 요약해 보면 마지막 페이지일 경우 content의 길이를 고려해 total이 조정될 수 있다고 하며, 이는 불일치를 완화하기 위함이라고 합니다.
- `filter(it -> !content.isEmpty())` : 현재 페이지의 결과가 비어있으면 스킵
- `filter(it -> it.getOffset() + it.getPageSize() > total)` : `offset + size`가 total보다 크다면, total 값이 실제보다 작게 전달된 것 같다고 판단
- `map(it -> it.getOffset() + content.size())` : 보정된 total 계산
- `orElse(total)` : 그 외 전달받은 total 그대로 사용
예시 1) - 정상적인 경우
`page=0`, `size=10`이며 `content.size=10`, `total=45`인 경우 `offset(0) + pageSize(10) <= total(45)` 이므로 그대로 total 사용 가능
예시 2) - 마지막 페이지
`page=4`, `size=10`이며 `content.size=5`, `total=45`인 경우 `offset(40) + pageSize(10) > total(45)` 이므로 total을 `offset(40) + content.size(5) = 45`로 보정
예시 3) - 잘못된 total 전달
`page=2`, `size=10`이며 `content.size=10`, `total=25`인 경우 `offset(20) + pageSize(10) > total(25)` 이므로 total을 `offset(20) + content.size(10) = 30`으로 보정
즉, 현재 페이지의 끝이 total보다 크다면 total을 현재 페이지 끝 위치로 보정하는 것이며 이는 total이 잘못 계산되어 전달된 경우를 방지하기 위해 현재 페이지 정보를 기반으로 total 값을 보정하는 방어적 코드를 포함한다고 볼 수 있습니다.
📌 정리
`PageableExecutionUtils`은 어떻게 페이징 쿼리를 최적화하는지 알아보았습니다. 최적화가 가능하다고 해서 항상 이 기능을 사용하는 것이 맞는지는 모르겠지만, 스프링에서 개발자를 위해 어떤 기능을 어떻게 제공하는지 알 수 있었습니다.
'Spring' 카테고리의 다른 글
| Spring AI 개발 일지 (2) - OpenAI 사용해 후기 요약 구현해보기 (0) | 2025.11.19 |
|---|---|
| Spring AI 개발 일지 (1) - Spring AI 소개와 핵심 모델 (0) | 2025.11.19 |
| Spring 비동기로 이메일 전송하기 (0) | 2025.10.21 |
| Spring AbstractRequestLoggingFilter로 RequestBody 로깅 찍기 (0) | 2025.10.14 |
| [데브코스] Spring + Toss payments로 결제 기능 개발하기 (0) | 2025.10.13 |