問い合わせ一覧が数百件になり、「全部取得してから画面で20件ずつ切る」実装が苦しくなった。Spring Data JPAなら、Repositoryへ Pageable を渡して取得範囲を限定できます。
ただし私は、PageRequest.of(page, size) を置くだけでは完成としません。ページ番号の0始まり、件数の上限、同日時データの順序まで決めます。これを曖昧にすると、最初のページが一つずれたり、ページを移動したとき同じ行が見えたりします。以下は Java 21、Spring Boot 3.5系、Spring Data JPA 3.5系を想定した架空の問い合わせ一覧です。
最初のページは0、表示上は1
PageRequest.of(0, 20) は最初の20件を要求します。次ページは PageRequest.of(1, 20) です。画面に「1ページ目」と出したいなら、表示時だけ page + 1 にします。
URLの page | Repositoryへ渡す値 | 画面の表示 |
|---|---|---|
| 0 | 0 | 1ページ目 |
| 1 | 1 | 2ページ目 |
URLも1始まりにしたいなら、入口で1を引いて内部の0始まりへ変換します。画面だけ1始まり、APIだけ0始まりのような混在を放置せず、公開するURLの契約を先に決めてください。
RepositoryとControllerの最小例
Inquiry は id、title、createdAt、status を持つ既存のJPA Entity、Status.OPEN は公開中の状態とします。Spring Bootアプリには spring-boot-starter-data-jpa とDBドライバーを追加します。
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
interface InquiryRepository extends JpaRepository<Inquiry, Long> {
Page<Inquiry> findByStatus(Status status, Pageable pageable);
}Controllerでは、利用者が自由に size=1000000 を指定できないようにします。また並び順は外部パラメータをそのままプロパティ名へ通さず、画面の仕様として固定します。
import java.util.List;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
record InquiryRow(Long id, String title) {}
record InquiryPage(List<InquiryRow> items, int page, int size,
long totalElements, int totalPages, boolean hasNext) {}
@RestController
class InquiryController {
private final InquiryRepository repository;
InquiryController(InquiryRepository repository) {
this.repository = repository;
}
@GetMapping("/inquiries")
InquiryPage list(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
if (page < 0 || size < 1 || size > 50) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"pageまたはsizeが範囲外です");
}
Sort order = Sort.by(Sort.Direction.DESC, "createdAt")
.and(Sort.by(Sort.Direction.DESC, "id"));
var result = repository.findByStatus(Status.OPEN,
PageRequest.of(page, size, order));
var rows = result.getContent().stream()
.map(i -> new InquiryRow(i.getId(), i.getTitle()))
.toList();
return new InquiryPage(rows, page, size, result.getTotalElements(),
result.getTotalPages(), result.hasNext());
}
}createdAt DESC だけだと、同じ時刻の行同士に一意の順序がありません。id DESC を最後に加えると、同じ時刻であっても並び順を決められます。ただし新規データがページ移動中に追加されれば、オフセット式ページングでは行の押し出しが起こり得ます。更新の多いタイムラインで「重複も抜けも許さない」なら、カーソル方式を検討します。
0件と最終ページを先に決める

たとえば全41件、1ページ20件なら page=0 と page=1 が20件ずつ、page=2 が1件です。page=3 は空になります。getTotalPages() は3、最後のページの hasNext() は false です。0件なら items=[]、totalElements=0 として画面に「該当なし」を出します。
範囲を超えたページ番号を404へするか、空の一覧として扱うかはAPIの契約です。この例では空一覧を返します。画面が「次へ」を出す判定に items.size() == size を使うと、最終ページがちょうど20件だった場合に間違えます。hasNext() を使ってください。
Page を選ぶ前に件数表示の要否を考える
Page は総件数・総ページ数を得られます。そのためカウント用の問い合わせが必要になる場合があります。「次があるか」だけでよい画面なら、Repositoryの戻り値を Slice<Inquiry> にして総件数の取得を避ける選択もあります。ページ番号を並べたいならPage、次へだけならSliceというのが最初の判断です。
実装後は、0件、1件、20件、21件、同じ createdAt の複数件で画面を確認します。最後にSQLログで、想定した件数制限と並び順が実際のDB問い合わせへ反映されているかを見てください。
