REST APIで大量のデータを扱う場合、すべてのレコードを一度に返却するのは現実的ではありません。Spring Data JPAが提供するPageableインターフェースを活用することで、効率的なページネーションとソート機能を簡潔に実装できます。本記事では、Pageableパラメータの受け取り方からPageとSliceの違い、クライアント向けのカスタムレスポンス設計まで、実践的なページネーション実装を解説します。
実行環境と前提条件#
本記事の内容を実践するにあたり、以下の環境を前提としています。
| 項目 |
バージョン・要件 |
| Java |
17以上 |
| Spring Boot |
3.4.x |
| Spring Data JPA |
3.4.x(Spring Boot Starterに含まれる) |
| データベース |
H2 Database(開発・テスト用インメモリDB) |
| ビルドツール |
Maven または Gradle |
事前に以下の準備を完了してください。
- JDK 17以上のインストール
- Spring Boot REST APIプロジェクトの基本構成(REST API入門記事を参照)
- Spring Data JPAとエンティティの基本設定(JPA入門記事を参照)
ページネーションの基本概念#
ページネーションとは、大量のデータを複数のページに分割して取得する仕組みです。Spring Data JPAでは、Pageableインターフェースを使用してページ番号、ページサイズ、ソート条件を指定できます。
ページネーション関連の主要インターフェース#
| インターフェース |
説明 |
Pageable |
ページ番号、ページサイズ、ソート条件を保持するリクエスト情報 |
Page<T> |
ページングされた結果と総件数・総ページ数を含むレスポンス |
Slice<T> |
ページングされた結果と次ページの有無のみを含む軽量レスポンス |
Sort |
ソート条件(プロパティ名と昇順/降順)を定義 |
Pageableパラメータの受け取り方#
Spring MVCでは、コントローラーのメソッド引数にPageableを指定するだけで、リクエストパラメータから自動的にページング情報を取得できます。
基本的なControllerの実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
package com.example.demoapi.controller;
import com.example.demoapi.entity.Task;
import com.example.demoapi.service.TaskService;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@GetMapping
public Page<Task> getTasks(Pageable pageable) {
return taskService.findAll(pageable);
}
}
|
デフォルトのリクエストパラメータ#
Pageableは以下のクエリパラメータを自動的に解釈します。
| パラメータ |
説明 |
デフォルト値 |
page |
ページ番号(0から開始) |
0 |
size |
1ページあたりの件数 |
20 |
sort |
ソート条件(プロパティ名,方向) |
なし |
以下のようなリクエストでページネーションを制御できます。
GET /api/tasks?page=0&size=10
GET /api/tasks?page=1&size=20&sort=createdAt,desc
GET /api/tasks?page=0&size=10&sort=title,asc&sort=createdAt,desc
デフォルト値のカスタマイズ#
@PageableDefaultアノテーションを使用して、デフォルトのページサイズやソート条件を指定できます。
1
2
3
4
5
6
7
8
9
|
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
@GetMapping
public Page<Task> getTasks(
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable) {
return taskService.findAll(pageable);
}
|
ページサイズの上限設定#
悪意のあるリクエストで極端に大きなページサイズが指定されることを防ぐため、application.propertiesで上限を設定できます。
1
2
3
4
|
# ページネーションのデフォルト設定
spring.data.web.pageable.default-page-size=20
spring.data.web.pageable.max-page-size=100
spring.data.web.pageable.one-indexed-parameters=false
|
| 設定項目 |
説明 |
default-page-size |
sizeパラメータ未指定時のデフォルト値 |
max-page-size |
sizeパラメータの最大許容値 |
one-indexed-parameters |
trueにするとページ番号が1から開始 |
ソート条件の指定方法#
Pageableに含まれるSortオブジェクトを使用して、動的なソートを実現できます。
クエリパラメータによるソート指定#
複数のソート条件を指定する場合は、sortパラメータを複数回指定します。
GET /api/tasks?sort=completed,asc&sort=createdAt,desc
この例では、まずcompletedで昇順ソートし、同じ値の場合はcreatedAtで降順ソートします。
Repositoryでのソート対応#
JpaRepositoryを継承したリポジトリでは、Pageableを引数に取るメソッドを定義するだけでソートに対応できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
package com.example.demoapi.repository;
import com.example.demoapi.entity.Task;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TaskRepository extends JpaRepository<Task, Long> {
// Pageableを受け取ることでページングとソートに対応
Page<Task> findByCompleted(boolean completed, Pageable pageable);
// タイトルで部分一致検索(ページング対応)
Page<Task> findByTitleContaining(String title, Pageable pageable);
}
|
Sortオブジェクトの直接使用#
コントローラーやサービス層でSortオブジェクトを明示的に作成することも可能です。
1
2
3
4
5
6
7
8
9
10
11
12
|
import org.springframework.data.domain.Sort;
// 単一条件でのソート
Sort sort = Sort.by("createdAt").descending();
// 複数条件でのソート
Sort multiSort = Sort.by("completed").ascending()
.and(Sort.by("createdAt").descending());
// タイプセーフなソート定義
Sort typedSort = Sort.sort(Task.class)
.by(Task::getCreatedAt).descending();
|
PageとSliceの違い#
Spring Data JPAは、ページング結果を返すためにPageとSliceという2つのインターフェースを提供しています。用途に応じて適切な方を選択することが重要です。
Pageインターフェース#
Pageは総件数と総ページ数を含む完全なページング情報を提供します。
1
2
3
|
public interface TaskRepository extends JpaRepository<Task, Long> {
Page<Task> findAll(Pageable pageable);
}
|
Pageオブジェクトが提供する主要なメソッドは以下のとおりです。
| メソッド |
説明 |
getContent() |
現在のページのデータリスト |
getTotalElements() |
全件数 |
getTotalPages() |
総ページ数 |
getNumber() |
現在のページ番号(0始まり) |
getSize() |
ページサイズ |
hasNext() |
次のページが存在するか |
hasPrevious() |
前のページが存在するか |
Sliceインターフェース#
Sliceは次のページが存在するかどうかのみを判定し、総件数を計算しません。これにより、大量データの場合にCOUNTクエリを省略できます。
1
2
3
|
public interface TaskRepository extends JpaRepository<Task, Long> {
Slice<Task> findByCompleted(boolean completed, Pageable pageable);
}
|
Sliceオブジェクトが提供する主要なメソッドは以下のとおりです。
| メソッド |
説明 |
getContent() |
現在のページのデータリスト |
getNumber() |
現在のページ番号(0始まり) |
getSize() |
ページサイズ |
hasNext() |
次のページが存在するか |
hasPrevious() |
前のページが存在するか |
PageとSliceの使い分け#
flowchart TD
A[ページネーション実装] --> B{総件数・総ページ数が必要?}
B -->|はい| C[Page を使用]
B -->|いいえ| D{無限スクロールUI?}
D -->|はい| E[Slice を使用]
D -->|いいえ| F{データ量が多い?}
F -->|はい| E
F -->|いいえ| C
C --> G[COUNTクエリが発行される]
E --> H[COUNTクエリが省略される]
| ユースケース |
推奨 |
理由 |
| 管理画面のデータ一覧 |
Page |
総件数を表示することが多い |
| 無限スクロールUI |
Slice |
総件数不要、次があるかだけ判定 |
| 大量データの一覧取得 |
Slice |
COUNTクエリのコスト削減 |
| ページ番号ナビゲーション |
Page |
総ページ数が必要 |
実行されるSQLの違い#
Pageを使用した場合、データ取得クエリに加えてCOUNTクエリが発行されます。
1
2
3
4
5
|
-- データ取得クエリ
SELECT * FROM tasks ORDER BY created_at DESC LIMIT 10 OFFSET 0;
-- COUNTクエリ(Pageの場合のみ)
SELECT COUNT(*) FROM tasks;
|
Sliceを使用した場合、リクエストされたサイズより1件多く取得し、次のページの有無を判定します。
1
2
|
-- サイズ+1件を取得して次ページの有無を判定
SELECT * FROM tasks ORDER BY created_at DESC LIMIT 11 OFFSET 0;
|
クライアント向けレスポンス形式の設計#
Spring Data JPAが返すPageオブジェクトをそのままJSONレスポンスとして返却することも可能ですが、APIの一貫性やクライアントの利便性を考慮してカスタムレスポンスを設計することを推奨します。
デフォルトのPageレスポンス構造#
Pageオブジェクトをそのまま返却した場合のJSONレスポンスは以下のようになります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
{
"content": [
{
"id": 1,
"title": "タスク1",
"description": "説明1",
"completed": false,
"createdAt": "2026-01-04T10:00:00",
"updatedAt": null
}
],
"pageable": {
"pageNumber": 0,
"pageSize": 10,
"sort": {
"sorted": true,
"unsorted": false,
"empty": false
},
"offset": 0,
"paged": true,
"unpaged": false
},
"totalElements": 50,
"totalPages": 5,
"last": false,
"first": true,
"size": 10,
"number": 0,
"sort": {
"sorted": true,
"unsorted": false,
"empty": false
},
"numberOfElements": 10,
"empty": false
}
|
このレスポンスは情報量が多く冗長なため、クライアントにとって扱いやすい形式にカスタマイズすることを検討します。
カスタムページネーションレスポンスの設計#
クライアントが必要とする情報のみを含むカスタムレスポンスクラスを作成します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
package com.example.demoapi.dto;
import org.springframework.data.domain.Page;
import java.util.List;
public record PageResponse<T>(
List<T> content,
PageInfo page
) {
public record PageInfo(
int number,
int size,
long totalElements,
int totalPages,
boolean first,
boolean last,
boolean hasNext,
boolean hasPrevious
) {}
public static <T> PageResponse<T> of(Page<T> page) {
return new PageResponse<>(
page.getContent(),
new PageInfo(
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isFirst(),
page.isLast(),
page.hasNext(),
page.hasPrevious()
)
);
}
}
|
Controllerでのカスタムレスポンス使用#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@GetMapping
public PageResponse<TaskResponse> getTasks(
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable) {
Page<Task> taskPage = taskService.findAll(pageable);
Page<TaskResponse> responsePage = taskPage.map(TaskResponse::from);
return PageResponse.of(responsePage);
}
}
|
DTOクラスの実装#
エンティティを直接返却するのではなく、DTOに変換してレスポンスします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
package com.example.demoapi.dto;
import com.example.demoapi.entity.Task;
import java.time.LocalDateTime;
public record TaskResponse(
Long id,
String title,
String description,
boolean completed,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public static TaskResponse from(Task task) {
return new TaskResponse(
task.getId(),
task.getTitle(),
task.getDescription(),
task.isCompleted(),
task.getCreatedAt(),
task.getUpdatedAt()
);
}
}
|
カスタムレスポンスのJSON出力例#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
{
"content": [
{
"id": 1,
"title": "タスク1",
"description": "説明1",
"completed": false,
"createdAt": "2026-01-04T10:00:00",
"updatedAt": null
}
],
"page": {
"number": 0,
"size": 10,
"totalElements": 50,
"totalPages": 5,
"first": true,
"last": false,
"hasNext": true,
"hasPrevious": false
}
}
|
Serviceクラスの実装#
ビジネスロジック層でページネーションを扱うServiceクラスの実装例を示します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
package com.example.demoapi.service;
import com.example.demoapi.entity.Task;
import com.example.demoapi.repository.TaskRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
public class TaskService {
private final TaskRepository taskRepository;
public TaskService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
public Page<Task> findAll(Pageable pageable) {
return taskRepository.findAll(pageable);
}
public Page<Task> findByCompleted(boolean completed, Pageable pageable) {
return taskRepository.findByCompleted(completed, pageable);
}
public Page<Task> searchByTitle(String keyword, Pageable pageable) {
return taskRepository.findByTitleContaining(keyword, pageable);
}
}
|
動作確認#
curlによるAPIテスト#
アプリケーションを起動し、以下のリクエストでページネーションの動作を確認します。
1
2
3
4
5
6
7
8
9
10
11
|
# デフォルトのページング(1ページ目、10件)
curl -X GET "http://localhost:8080/api/tasks" | jq
# 2ページ目を取得
curl -X GET "http://localhost:8080/api/tasks?page=1&size=10" | jq
# ソート条件を指定
curl -X GET "http://localhost:8080/api/tasks?sort=title,asc&sort=createdAt,desc" | jq
# 完了済みタスクのみを取得(カスタムエンドポイント)
curl -X GET "http://localhost:8080/api/tasks?completed=true&page=0&size=5" | jq
|
期待されるレスポンス#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
{
"content": [
{
"id": 10,
"title": "最新のタスク",
"description": "説明",
"completed": false,
"createdAt": "2026-01-04T15:30:00",
"updatedAt": null
},
{
"id": 9,
"title": "2番目のタスク",
"description": "説明",
"completed": true,
"createdAt": "2026-01-04T14:00:00",
"updatedAt": "2026-01-04T14:30:00"
}
],
"page": {
"number": 0,
"size": 10,
"totalElements": 25,
"totalPages": 3,
"first": true,
"last": false,
"hasNext": true,
"hasPrevious": false
}
}
|
ページネーション実装のベストプラクティス#
パフォーマンス最適化#
- 適切なインデックスの作成: ソートやフィルタリングに使用するカラムにはインデックスを作成する
- N+1問題の回避:
@EntityGraphやJOIN FETCHを使用して関連エンティティを効率的に取得する
- Sliceの活用: 総件数が不要な場合は
Sliceを使用してCOUNTクエリを省略する
セキュリティ考慮事項#
- ページサイズの上限設定:
max-page-sizeを設定して過大なリクエストを防止する
- ソート対象プロパティの制限: 任意のプロパティでソートを許可せず、許可リストを設ける
- オフセットの上限検討: 極端に大きなオフセットはパフォーマンス低下を招くため制限を検討する
カスタムソートプロパティの制限例#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
@GetMapping
public PageResponse<TaskResponse> getTasks(
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "desc") String direction,
@PageableDefault(size = 10) Pageable pageable) {
// 許可されたソートプロパティのみを受け入れる
Set<String> allowedSortProperties = Set.of("id", "title", "createdAt", "completed");
if (!allowedSortProperties.contains(sortBy)) {
throw new IllegalArgumentException("Invalid sort property: " + sortBy);
}
Sort.Direction sortDirection = "asc".equalsIgnoreCase(direction)
? Sort.Direction.ASC
: Sort.Direction.DESC;
Pageable customPageable = PageRequest.of(
pageable.getPageNumber(),
pageable.getPageSize(),
Sort.by(sortDirection, sortBy)
);
Page<Task> taskPage = taskService.findAll(customPageable);
return PageResponse.of(taskPage.map(TaskResponse::from));
}
|
アーキテクチャ全体像#
ページネーション実装における各レイヤーの責務を以下の図に示します。
sequenceDiagram
participant Client as クライアント
participant Controller as Controller
participant Service as Service
participant Repository as Repository
participant DB as Database
Client->>Controller: GET /api/tasks?page=0&size=10&sort=createdAt,desc
Controller->>Controller: Pageableオブジェクト生成
Controller->>Service: findAll(Pageable)
Service->>Repository: findAll(Pageable)
Repository->>DB: SELECT ... LIMIT 10 OFFSET 0
Repository->>DB: SELECT COUNT(*)
DB-->>Repository: データ + 総件数
Repository-->>Service: Page<Task>
Service-->>Controller: Page<Task>
Controller->>Controller: PageResponse変換
Controller-->>Client: JSON Responseまとめ#
本記事では、Spring Boot REST APIにおけるページネーション実装について解説しました。
Pageableインターフェースにより、ページ番号・サイズ・ソート条件をシンプルに受け取れる
@PageableDefaultでデフォルト値をカスタマイズできる
Pageは総件数を含む完全な情報、Sliceは軽量な次ページ判定のみを提供する
- カスタムレスポンスクラスを作成することで、APIの一貫性とクライアントの利便性を向上できる
- ページサイズの上限設定やソートプロパティの制限によりセキュリティを確保する
ページネーションは、実用的なREST APIを構築する上で必須の機能です。Spring Data JPAの豊富な機能を活用して、効率的で使いやすいAPIを実装してください。
参考リンク#