1・2・3ページのカードと安定した並び順を示す図。ページングの件数と順序を確認する

実装・エラー解決

Spring Data JPAでページングする方法|Pageableのページ番号・件数・並び順

問い合わせ一覧が数百件になり、「全部取得してから画面で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の pageRepositoryへ渡す値画面の表示
001ページ目
112ページ目

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件と最終ページを先に決める

ページングで確認する4条件。件数上限:要求値をそのまま使わない。並び順:同順位でも順序を安定。0件:空の一覧を正常に返す。最終ページ:次の有無を確認する
ページングで確認する4条件。件数と並び順を固定して確認

たとえば全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問い合わせへ反映されているかを見てください。

参考資料

-実装・エラー解決
-