大量データを返す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を受け取るだけで、クエリパラメータから自動的にページネーション情報を取得できます。
@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);
}
}
このエンドポイントには、?page=0&size=10のようなクエリパラメータでアクセスできます。ソート条件も&sort=name,ascの形式で指定可能です。
重要 Spring Data JPAのページ番号は 0始まり です。最初のページはpage=0となります。
@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件、作成日時の降順で結果が返されます。
RepositoryでPageを返す実装
JpaRepositoryを継承したリポジトリインターフェースでは、カスタムクエリメソッドの引数にPageableを追加し、戻り値をPage<T>にするだけでページネーション対応になります。
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()で総ページ数など、ページング情報にアクセスするメソッドが用意されています。
完全なコード例 - Entity、Repository、Controller
実際に動作する完全なページネーションAPIを実装してみましょう。
// Entity
@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
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
Page<Product> findByCategory(String category, Pageable pageable);
}
// Controller
@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=category,asc&sort=price,descのように複数のsortパラメータを指定した場合、左から順に優先されます。
存在しないフィールド名を指定した場合、Spring Data JPAはPropertyReferenceExceptionをスローします。本番環境では、この例外を適切にハンドリングし、クライアントに分かりやすいエラーメッセージを返すことを推奨します。
デフォルトで複数のソート条件を設定したい場合は、@PageableDefaultのsortパラメータに配列で指定できます。
レスポンス形式のカスタマイズ - PageをDTOに変換
デフォルトのPageレスポンスには不要な情報が多く含まれる場合があります。必要な情報だけを返すカスタムレスポンスDTOを作成しましょう。
// ProductDTO
@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();
}
}
// PageResponse
@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);
// ページ番号が範囲外の場合、例外をスロー
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);
}
}
// 例外ハンドラー
@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;
}
}
統一的なエラーレスポンスについては、Spring BootのREST APIで統一的なエラーレスポンスを返す方法で詳しく解説しています。
ページネーション実装のベストプラクティス
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件までしか返されません。
大量データでのページネーションの注意点
数百万件を超えるデータで大きなoffset(例: 10,000ページ目以降)を指定すると、データベースがスキップする行数が多くなりパフォーマンスが著しく低下します。例えば、1000万件のデータで10,000ページ目(offset=100,000)にアクセスする場合、データベースは最初の100,000行を読み飛ばす必要があります。
このような大規模データでは、カーソルベースページネーション(キーセットページネーション、例: WHERE id > last_id LIMIT 20形式)の採用を検討してください。本記事では扱いませんが、offsetベースのページネーションが実用的でない規模に達した場合の有力な代替手段です。
まとめ
この記事では、Spring Data JPAのPageableとPageを使ったREST APIのページネーション実装を解説しました。
コントローラーでPageableを受け取るだけでクエリパラメータから自動的にページング情報を取得でき、@PageableDefaultでデフォルト値を設定できます。Page<Entity>をPage<DTO>に変換する場合はmap()メソッドを使うと簡単です。
大量データを扱うAPIでは、ページネーションは必須の機能ですよね。本番環境では、application.ymlで最大ページサイズを制限し、適切なエラーハンドリングを実装することをお勧めします。
関連する実装として、Spring Data JPAのエンティティ関連付けやSpring BootのREST APIで統一的なエラーレスポンスを返す方法も参考にしてみてください。