Spring BootのREST APIでページネーションを実装する方法 - PageableとPageの使い方
大量データを返すREST APIでパフォーマンス問題に直面している開発者に向けて、Spring Data JPAのPageableとPageを使ったページネーション実装の基本から実践までを解説します。この記事を読めば、クエリパラメータでページ番号・サイズ・ソート条件を受け取り、適切なJSON形式でページング情報を返すエンドポイントを実装できるようになります。
本記事はSpring Boot 3.x系を前提としています。 Spring Boot 2.x系をお使いの場合、設定パスが一部異なる場合があります。
ページネーションとは - なぜREST APIで必要なのか
REST APIで全件のデータを一度に返すと、以下のような問題が発生します。
- メモリ消費の増大: 数千件、数万件のデータを一度にメモリに読み込むとサーバーのメモリを圧迫
- レスポンス時間の遅延: 大量データのシリアライズとネットワーク転送に時間がかかる
- ネットワーク帯域の浪費: クライアントが必要としない部分まで転送してしまう
ページネーションは、データを一定件数ずつ分割して取得することでこれらの問題を解決します。Spring Data JPAは、PageableインターフェースとPageインターフェースを使って、ページネーション機能を簡単に実装できる仕組みを提供しています。
Pageableの基本的な使い方
Spring Data JPAでは、コントローラーのメソッド引数としてPageableを受け取るだけで、クエリパラメータから自動的にページネーション情報を取得できます。
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ProductController {
private final ProductRepository productRepository;
public ProductController(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@GetMapping("/api/products")
public Page<Product> getProducts(Pageable pageable) {
return productRepository.findAll(pageable);
}
}
このエンドポイントには、以下のようなクエリパラメータでアクセスできます。
# ページ番号0、サイズ10でリクエスト
curl "http://localhost:8080/api/products?page=0&size=10"
# ページ番号1、サイズ20、名前の昇順でソート
curl "http://localhost:8080/api/products?page=1&size=20&sort=name,asc"
重要: Spring Data JPAのページ番号は 0始まり です。最初のページはpage=0となります。
@PageableDefaultアノテーションでデフォルト値を設定
クエリパラメータが指定されない場合のデフォルト値を@PageableDefaultアノテーションで設定できます。
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
@GetMapping("/api/products")
public Page<Product> getProducts(
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable
) {
return productRepository.findAll(pageable);
}
この設定により、パラメータなしでアクセスした場合、1ページあたり20件、作成日時の降順で結果が返されます。
注意: sortパラメータに複数のフィールドを配列で指定した場合(例: sort = {"category", "price"})、directionパラメータで指定した方向がすべてのフィールドに適用されます。フィールドごとに異なるソート方向を指定したい場合は、クエリパラメータで個別に指定する必要があります。
RepositoryでPageを返す実装
JpaRepositoryを継承したリポジトリインターフェースでは、カスタムクエリメソッドの引数にPageableを追加し、戻り値をPage<T>にするだけでページネーション対応になります。
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
// カテゴリで絞り込み(ページネーション対応)
Page<Product> findByCategory(String category, Pageable pageable);
// 価格範囲で絞り込み(ページネーション対応)
Page<Product> findByPriceBetween(Integer minPrice, Integer maxPrice, Pageable pageable);
}
JpaRepositoryが提供するfindAll(Pageable pageable)メソッドは継承するだけで使用できるため、リポジトリで再定義する必要はありません。
Page<T>インターフェースには、以下のような情報が含まれます。
getContent(): 現在のページのデータリストgetTotalElements(): 全体の総件数getTotalPages(): 総ページ数getNumber(): 現在のページ番号(0始まり)getSize(): 1ページあたりのサイズhasNext(): 次のページが存在するかhasPrevious(): 前のページが存在するか
完全なコード例 - Entity、Repository、Controller
実際に動作する完全なページネーションAPIを実装してみましょう。
Entityクラスの定義
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String category;
private Integer price;
private LocalDateTime createdAt;
// コンストラクタ、getter、setterは省略
}
Repositoryインターフェース
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
// findAll(Pageable)はJpaRepositoryに定義済みのため再定義不要
Page<Product> findByCategory(String category, Pageable pageable);
}
Controllerクラス
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductRepository productRepository;
public ProductController(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@GetMapping
public Page<Product> getProducts(
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable
) {
return productRepository.findAll(pageable);
}
}
レスポンス例
{
"content": [
{
"id": 1,
"name": "商品A",
"category": "電化製品",
"price": 10000,
"createdAt": "2026-01-15T10:00:00"
},
{
"id": 2,
"name": "商品B",
"category": "家具",
"price": 25000,
"createdAt": "2026-01-15T15:30:00"
}
],
"pageable": {
"sort": {
"sorted": true,
"unsorted": false,
"empty": false
},
"pageNumber": 0,
"pageSize": 10,
"offset": 0,
"paged": true,
"unpaged": false
},
"totalPages": 5,
"totalElements": 50,
"last": false,
"first": true,
"size": 10,
"number": 0,
"sort": {
"sorted": true,
"unsorted": false,
"empty": false
},
"numberOfElements": 10,
"empty": false
}
ソート条件の指定方法
クエリパラメータで複数のソート条件を指定できます。複数のsortパラメータを指定した場合、左から順に優先されます。
# 単一フィールドで昇順ソート
curl "http://localhost:8080/api/products?sort=name,asc"
# 単一フィールドで降順ソート
curl "http://localhost:8080/api/products?sort=price,desc"
# 複数フィールドでソート(カテゴリの昇順が優先、同じカテゴリ内で価格の降順)
curl "http://localhost:8080/api/products?sort=category,asc&sort=price,desc"
存在しないフィールド名を指定した場合、Spring Data JPAはPropertyReferenceExceptionをスローします。本番環境では、この例外を適切にハンドリングし、クライアントに分かりやすいエラーメッセージを返すことを推奨します。
@PageableDefaultで複数ソート条件を設定
@GetMapping
public Page<Product> getProducts(
@PageableDefault(
size = 20,
sort = {"category", "price"},
direction = Sort.Direction.ASC
)
Pageable pageable
) {
return productRepository.findAll(pageable);
}
レスポンス形式のカスタマイズ - PageをDTOに変換
デフォルトのPageレスポンスには不要な情報が多く含まれる場合があります。必要な情報だけを返すカスタムレスポンスDTOを作成しましょう。
DTOクラスの定義
import lombok.Getter;
@Getter
public class ProductDTO {
private final Long id;
private final String name;
private final String category;
private final Integer price;
public ProductDTO(Product product) {
this.id = product.getId();
this.name = product.getName();
this.category = product.getCategory();
this.price = product.getPrice();
}
}
カスタムレスポンスクラス
import lombok.Getter;
import java.util.List;
@Getter
public class PageResponse<T> {
private final List<T> content;
private final long totalElements;
private final int totalPages;
private final int currentPage;
private final int pageSize;
private final boolean hasNext;
private final boolean hasPrevious;
public PageResponse(org.springframework.data.domain.Page<T> page) {
this.content = page.getContent();
this.totalElements = page.getTotalElements();
this.totalPages = page.getTotalPages();
this.currentPage = page.getNumber();
this.pageSize = page.getSize();
this.hasNext = page.hasNext();
this.hasPrevious = page.hasPrevious();
}
}
PageをPageに変換
Pageインターフェースのmap()メソッドを使うと、簡単に変換できます。
@GetMapping
public PageResponse<ProductDTO> getProducts(
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable
) {
Page<Product> productPage = productRepository.findAll(pageable);
Page<ProductDTO> dtoPage = productPage.map(ProductDTO::new);
return new PageResponse<>(dtoPage);
}
これにより、以下のようなシンプルなレスポンスになります。
{
"content": [
{
"id": 1,
"name": "商品A",
"category": "電化製品",
"price": 10000
}
],
"totalElements": 50,
"totalPages": 5,
"currentPage": 0,
"pageSize": 10,
"hasNext": true,
"hasPrevious": false
}
ページ番号範囲外のエラーハンドリング
ページ番号が総ページ数を超えた場合、Spring Data JPAは空のリストを返します。エラーとして扱いたい場合は、明示的にチェックする必要があります。
@GetMapping
public PageResponse<ProductDTO> getProducts(
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable
) {
Page<Product> productPage = productRepository.findAll(pageable);
// ページ番号が範囲外の場合、例外をスロー
// 総件数が0の場合は0ページ目のみ有効、それ以外はページ番号が総ページ数未満である必要がある
if (productPage.getTotalElements() > 0 &&
pageable.getPageNumber() >= productPage.getTotalPages()) {
throw new PageOutOfBoundsException(
String.format("ページ番号 %d は範囲外です。総ページ数: %d",
pageable.getPageNumber(), productPage.getTotalPages())
);
}
Page<ProductDTO> dtoPage = productPage.map(ProductDTO::new);
return new PageResponse<>(dtoPage);
}
カスタム例外クラス
public class PageOutOfBoundsException extends RuntimeException {
public PageOutOfBoundsException(String message) {
super(message);
}
}
例外ハンドラー
統一的なエラーレスポンスについては、Spring BootのREST APIで統一的なエラーレスポンスを返す方法で詳しく解説しています。
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(PageOutOfBoundsException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handlePageOutOfBounds(PageOutOfBoundsException ex) {
return new ErrorResponse("INVALID_PAGE", ex.getMessage());
}
@Getter
@AllArgsConstructor
public static class ErrorResponse {
private String code;
private String message;
}
}
ページネーション実装のベストプラクティス
application.ymlでグローバル設定
spring:
data:
web:
pageable:
default-page-size: 20 # デフォルトのページサイズ
max-page-size: 100 # 最大ページサイズ
one-indexed-parameters: false # ページ番号を1始まりにする(デフォルトはfalse)
Spring Boot 3.x系での注意: 上記の設定パスは Spring Boot 3.x 以降で有効です。Spring Boot 2.x をお使いの場合は、設定パスが異なる可能性があります。
最大ページサイズの制限
max-page-sizeを設定することで、クライアントが過大なサイズを指定した場合でも自動的に制限されます。例えばmax-page-size: 100の場合、?size=1000とリクエストしても最大100件までしか返されません。
ページ番号を1始まりにする設定
one-indexed-parameters: trueを設定すると、ページ番号を1始まりにできます。ただし、内部的には0始まりで処理されるため、混乱を避けるためデフォルトの0始まりを推奨します。
大量データでのページネーションの注意点
数百万件を超えるデータで大きなoffset(例: 10,000ページ目以降)を指定すると、データベースがスキップする行数が多くなりパフォーマンスが著しく低下します。例えば、1000万件のデータで10,000ページ目(offset=100,000)にアクセスする場合、データベースは最初の100,000行を読み飛ばす必要があります。
このような大規模データでは、カーソルベースページネーション(キーセットページネーション、例: WHERE id > last_id LIMIT 20形式)の採用を検討してください。本記事では扱いませんが、offsetベースのページネーションが実用的でない規模に達した場合の有力な代替手段です。
よくある質問(FAQ)
ページ番号は0始まりですか、1始まりですか?
デフォルトでは 0始まり です。最初のページはpage=0となります。application.ymlでspring.data.web.pageable.one-indexed-parameters: trueを設定することで1始まりに変更できますが、内部処理は0始まりのため、デフォルトのまま使用することを推奨します。
最大ページサイズを制限する方法は?
application.ymlで以下のように設定します。
spring:
data:
web:
pageable:
max-page-size: 100
これにより、クライアントが?size=1000のような大きな値を指定しても、最大100件までに制限されます。
PageとPageの変換はどうやるの?
Pageインターフェースのmap()メソッドを使用します。
Page<Product> productPage = productRepository.findAll(pageable);
Page<ProductDTO> dtoPage = productPage.map(ProductDTO::new);
ページ番号が範囲外の場合はどうなりますか?
デフォルトでは空のリスト(content: [])が返されます。エラーとして扱いたい場合は、本文中の「ページ番号範囲外のエラーハンドリング」セクションのように明示的なチェックが必要です。
複数フィールドでソートする方法は?
クエリパラメータで複数のsortを指定します。左から順に優先されます。
curl "http://localhost:8080/api/products?sort=category,asc&sort=price,desc"
デフォルトのソート条件を設定できますか?
@PageableDefaultアノテーションで設定できます。
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable
Pageableを使わずにページネーションを実装できますか?
可能ですが推奨しません。Pageableを使わない場合、pageとsizeを個別に受け取り、手動でPageRequest.of(page, size)を作成する必要があります。Pageableを使うことでソート条件の自動パースなど多くの機能が自動化されるため、特別な理由がない限りPageableの使用を推奨します。
カスタムクエリメソッドでもPageableは使えますか?
使えます。メソッドの引数にPageableを追加し、戻り値をPage<T>にするだけです。
Page<Product> findByCategory(String category, Pageable pageable);
Page<Product> findByPriceBetween(Integer min, Integer max, Pageable pageable);
まとめ - 次のステップ
この記事では、Spring Data JPAのPageableとPageを使ったREST APIのページネーション実装を解説しました。
- コントローラーで
Pageableを受け取り、クエリパラメータから自動的にページング情報を取得 @PageableDefaultでデフォルトのページサイズとソート条件を設定Page<Entity>をPage<DTO>に変換し、カスタムレスポンス形式で返す- ページ番号範囲外のエラーハンドリングと適切な例外処理
- application.ymlでの最大ページサイズ制限などのベストプラクティス
ページネーションの実装ができたら、以下のトピックも学習すると、より実践的なAPIが構築できます。
- Spring Data JPAのエンティティ関連付けでエンティティ間の関連を理解し、ページネーション時のデータ取得を最適化
- Spring BootのREST APIで統一的なエラーレスポンスを返す方法でページネーションのエラーハンドリングを改善
- JUnitとMockitoを使ったテストでページネーションAPIの動作を検証し、品質を保証