Spring BootのPageableによるページネーション・ソート

1. 概要

全体のordersテーブルを返すAPIは100行程度なら正常に動作します。しかしテーブルが100万行に達すると、そのAPIはアプリケーションをクラッシュさせてしまいます。Spring Dataはこの問題をPageableで解決します。これは「どのページを取得するか、何個の要素か、どのようにソートするか」を表すオブジェクトです。

このガイドを読み終わると、以下の方法が分かります:

  • ページング機能を持つrepositoryメソッドの宣言
  • PageとSliceの使い分け
  • HTTPリクエストから直接page、size、sortパラメータを受け取る
  • パフォーマンスとセキュリティに関する落とし穴を回避する

2. セットアップ

Spring Data JPA、Spring Web、そしてデータベースが必要です。このガイドではシンプルにするため、H2を使用します:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

サンプル用のentityは注文です:

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String customerName;

    private BigDecimal total;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    private LocalDateTime createdAt;

    protected Order() {
    }

    public Order(String customerName, BigDecimal total, OrderStatus status, LocalDateTime createdAt) {
        this.customerName = customerName;
        this.total = total;
        this.status = status;
        this.createdAt = createdAt;
    }

    // getters
}

public enum OrderStatus { NEW, PAID, SHIPPED }

テーブル名をordersとした理由は、ORDERがSQL予約語だからです。デフォルトのまま使用すると、多くのデータベースで構文エラーが発生します。

3. repositoryレベルでのページング

3.1. PageableとPageRequest

JpaRepositoryはPagingAndSortingRepositoryを継承しているため、最初からfindAll(Pageable)メソッドが利用できます。Pageableを作成するには、PageRequest.ofを使います:

Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());
Page<Order> page = orderRepository.findAll(pageable);

System.out.println("ページ: " + page.getNumber());
System.out.println("このページの要素数: " + page.getNumberOfElements());
System.out.println("総要素数: " + page.getTotalElements());
System.out.println("総ページ数: " + page.getTotalPages());
System.out.println("次ページ有?: " + page.hasNext());

各パラメータの意味:

  • 0はページのインデックスです。Spring Dataはページを1からではなく0からカウントします。
  • 10は1ページあたりの要素数です。
  • Sort.by("createdAt").descending()は新しい注文を最初に表示するようにソートします。

テーブルに25件の注文がある場合、出力は以下のようになります:

ページ: 0
このページの要素数: 10
総要素数: 25
総ページ数: 3
次ページ有?: true

spring.jpa.show-sql=trueを有効にすると、Hibernateが2つのSQLステートメントを実行していることが分かります:

select o.id, o.created_at, o.customer_name, o.status, o.total
  from orders o order by o.created_at desc offset ? rows fetch first ? rows only
select count(o.id) from orders o

最初のステートメントはそのページのデータを取得します。2番目のステートメントは行数を数えて、totalElementsとtotalPagesを計算します。offset ... fetch first構文はH2のものです。MySQLやPostgreSQLでは、limit ? offset ?が表示されます。

3.2. 派生クエリでのページング

メソッドの最後にPageableパラメータを追加するだけです。Spring Dataが残りを処理します:

public interface OrderRepository extends JpaRepository<Order, Long> {

    Page<Order> findByStatus(OrderStatus status, Pageable pageable);

    Slice<Order> findByCustomerName(String customerName, Pageable pageable);

    List<Order> findTop5ByStatus(OrderStatus status, Sort sort);
}
  • findByStatusはPageを返し、countクエリが伴います。
  • findByCustomerNameはSliceを返します。セクション4で違いについて説明します。
  • findTop5ByStatusはソートのみが必要で、ページングは不要です。この場合、Pageableの代わりにSortを渡します。

3.3. 複数のソート条件を組み合わせる

Sortは複数の列を組み合わせることができます:

Sort sort = Sort.by(Sort.Order.desc("total"), Sort.Order.asc("createdAt"));
Pageable pageable = PageRequest.of(1, 5, sort);

Page<Order> paidOrders = orderRepository.findByStatus(OrderStatus.PAID, pageable);

このコードは2ページ目(6~10番目の要素)の支払済み注文を取得します。総額が大きい注文が先に並びます。金額が同じ場合、古い注文が最初に表示されます。

注意: ページング結果の安定性を保つため、常に少なくとも1つのユニークな列でソートしてください。例えばidを最後に追加します。複数の行がtotalで同じ値を持つ場合、異なるリクエスト間で順序が異なる可能性があります。その結果、レコードが2つのページに表示されたり、両方のページから消えたりすることがあります。

4. PageかSliceか?

Page<T> Slice<T>
countクエリ あり なし
getTotalElements()、getTotalPages() あり なし
hasNext() あり あり
適用例 ページ番号付きテーブル(1, 2, 3 … 50) Infinite scroll、「もっと見る」ボタン

Sliceはcountクエリを実行しません。代わりにsize + 1行を取得します。余分な行が返されたら、次ページがあることが分かります:

Slice<Order> slice = orderRepository.findByCustomerName("An", PageRequest.of(0, 10));
System.out.println(slice.getContent().size() + " " + slice.hasNext());
10 true

大規模なテーブルでは、count(*)はメインクエリと同じくらい遅いことがあります。UIでページ総数を表示する必要がなければ、Sliceを使用しましょう。

5. HTTPリクエストからPageableを受け取る

5.1. Controller

Spring BootはPageableHandlerMethodArgumentResolverを自動的に設定します。したがって、controllerのパラメータとしてPageableを宣言するだけで十分です:

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderRepository orderRepository;

    public OrderController(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @GetMapping
    public PagedModel<OrderResponse> list(
            @RequestParam OrderStatus status,
            @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
            Pageable pageable) {

        Page<OrderResponse> page = orderRepository.findByStatus(status, pageable)
                .map(OrderResponse::from);
        return new PagedModel<>(page);
    }
}

public record OrderResponse(Long id, String customerName, BigDecimal total, LocalDateTime createdAt) {
    static OrderResponse from(Order o) {
        return new OrderResponse(o.getId(), o.getCustomerName(), o.getTotal(), o.getCreatedAt());
    }
}

注意すべき点:

  • @PageableDefaultは、クライアントがpage、size、またはsortを送信しない場合のデフォルト値を設定します。
  • page.map(...)はentityをDTOに変換しつつ、ページング情報を保持します。entityをAPI外に返すべきではありません。
  • PagedModel(パッケージorg.springframework.data.web、Spring Data 3.3 / Boot 3.3以降)はPageを安定したJSON構造にラップします。

クライアントは次のようにAPIを呼び出します:

curl "http://localhost:8080/orders?status=PAID&page=0&size=2&sort=total,desc&sort=id,asc"
{
  "content": [
    { "id": 17, "customerName": "Bình", "total": 990000.00, "createdAt": "2026-10-01T09:15:00" },
    { "id": 4,  "customerName": "An",   "total": 870000.00, "createdAt": "2026-09-28T14:02:00" }
  ],
  "page": { "size": 2, "number": 0, "totalElements": 12, "totalPages": 6 }
}

sortパラメータは属性,方向の形式です。複数の列でソートするために複数回使用できます。

5.2. なぜ直接Pageを返さないのか?

Page<OrderResponse>を返すことも可能です。しかし、その場合のJSONはPageImpl(内部クラス)をシリアライズした結果なので、構造がバージョン間で変わる可能性があります。Spring Data 3.3以降、このようにすると、ログに警告が表示されます。対処方法は2つあります:上記のようにPagedModelを返すか、アプリケーション全体の自動変換モードを有効にします:

@Configuration
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
public class WebConfig {
}

5.3. グローバル設定

spring.data.web.pageable.default-page-size=20
spring.data.web.pageable.max-page-size=100
spring.data.web.pageable.one-indexed-parameters=false
spring.data.web.pageable.page-parameter=page
spring.data.web.pageable.size-parameter=size
spring.data.web.sort.sort-parameter=sort

max-page-sizeは最も重要なプロパティです。クライアントがsize=1000000を送信した場合、Springは自動的に100に制限します。デフォルトの制限は2000で、ほとんどのAPIには大きすぎます。

one-indexed-parameters=trueはクライアントが最初のページでpage=1を送信できるようにします。内部では、Pageableはまだ0ベースのインデックスを使用します。

6. @Queryでのページング

独自に書いたJPQLでも、Spring Dataは自動的にorder byとlimit/offsetを追加します。また、クエリからcountクエリも推測しようとします。ただし、join fetchを含むクエリの場合、この推測はしばしば間違うため、countQueryを別途定義する必要があります:

@Query(value = "select o from Order o join fetch o.customer c where c.city = :city",
       countQuery = "select count(o) from Order o join o.customer c where c.city = :city")
Page<Order> findByCity(@Param("city") String city, Pageable pageable);

このコード例は、OrderがCustomerエンティティとの@ManyToOne関係を持つと想定しています。@ManyToOneの場合、join fetchとページングは正常に連動します。

注意: ページング時に@OneToManyなどのコレクションを含むjoin fetchを使用しないでください。Hibernateはこの場合、SQLレベルでlimitを適用できません。すべてのデータをメモリにロードしてからページング処理を行い、HHH90003004: firstResult/maxResults specified with collection fetch; applying in memoryという警告が出ます。正しい方法は、まずIDでページングし、その後、そのIDリストに基づいてコレクションを取得することです。

7. テスト

@DataJpaTestは組み込みデータベースを起動し、JPAレイヤーのみをロードするため、repositoryのテストに最適です:

@DataJpaTest
class OrderRepositoryTest {

    @Autowired
    OrderRepository orderRepository;

    @BeforeEach
    void setUp() {
        LocalDateTime base = LocalDateTime.of(2026, 10, 1, 8, 0);
        for (int i = 1; i <= 25; i++) {
            orderRepository.save(new Order("顧客 " + i, BigDecimal.valueOf(i * 10_000L),
                    OrderStatus.PAID, base.plusHours(i)));
        }
    }

    @Test
    void shouldReturnSecondPageSortedByTotalDesc() {
        Pageable pageable = PageRequest.of(1, 10, Sort.by("total").descending());

        Page<Order> page = orderRepository.findByStatus(OrderStatus.PAID, pageable);

        assertThat(page.getTotalElements()).isEqualTo(25);
        assertThat(page.getTotalPages()).isEqualTo(3);
        assertThat(page.getContent()).hasSize(10);
        assertThat(page.getContent().getFirst().getTotal())
                .isEqualByComparingTo("150000");
        assertThat(page.hasNext()).isTrue();
    }

    @Test
    void lastPageShouldContainRemainingOrders() {
        Page<Order> page = orderRepository.findByStatus(OrderStatus.PAID, PageRequest.of(2, 10));

        assertThat(page.getContent()).hasSize(5);
        assertThat(page.isLast()).isTrue();
    }
}

最初のテストでは、ページ1(2番目のページ)には11番目から20番目の要素が含まれます。ページの最初の要素は11番目に大きい注文、つまり25 − 10 = 15で、150,000に対応します。2番目のテストは最後のページに5件の注文しかないことを確認します。

8. よくあるエラー

存在しない属性でソートする。 ?sort=passwordHashや?sort=abcリクエストはPropertyReferenceExceptionを引き起こします。対処しないと、クライアントは500エラーを受け取ります。さらに悪いことに、クライアントは機密列でソートしてデータを探ることができます。ホワイトリストでフィルタリングすべきです:

private static final Set<String> SORTABLE = Set.of("createdAt", "total", "id");

static Pageable sanitize(Pageable pageable) {
    Sort safe = Sort.by(pageable.getSort().stream()
            .filter(order -> SORTABLE.contains(order.getProperty()))
            .toList());
    return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
            safe.isSorted() ? safe : Sort.by("createdAt").descending());
}

オフセットが深すぎる。 page=50000&size=20はデータベースに100万行を読み込んでスキップさせます。クエリ時間はオフセットに応じて直線的に増加します。大規模なデータまたはinfinite scrollの場合は、キーセットページング(「id < lastSeenIdの行を取得」)を使用しましょう。Spring DataはScrollPositionとWindowを使ってこの方法をサポートしています。

ページが1から始まると勘違いする。 PageRequest.of(1, 10)は2番目のページです。フロントエンドが1からカウントする場合、1を引くのではなく、one-indexed-parametersを有効にしましょう。

リクエスト間でデータが変わる。 オフセットベースのページングは、新しいレコードが挿入されると不安定になります。ユーザーはページを切り替える時に同じ注文が重複して表示されることがあります。キーセットページングはこの問題を回避します。

Pageを使うべきではない場合: テーブルが非常に大きく、UIでページ総数が不要な場合。例えば、フィードやログです。countクエリはその場合余分なコストになるため、SliceまたはWindowを使用してください。

9. まとめ

Pageableは3つの情報(ページ、サイズ、ソート方法)を1つのオブジェクトにまとめます。Spring Dataはそれを派生クエリ、@Query、controllerパラメータまで、すべてのレイヤーでlimit/offsetとorder byに変換します。ページ総数が必要な場合はPageを、データの有無だけ知れば良い場合はSliceを使用します。JSONを返す際は、レスポンス構造を安定させるためPagedModelを使用しましょう。

3つのことを忘れずに:max-page-sizeを制限し、ホワイトリストの属性でのみソートを許可し、常にユニークな列でソートします。オフセットが遅くなったら、キーセットページングへの移行を検討してください。

spring-bootspring-data-jpapageablerest-apijpa