Spring Data JPAなら Pageable を渡すだけで済むページングも、MyBatisだと「で、どう書けばいいの?」と手が止まりがちですよね。RowBounds を使ってみたら全件取っている気がする、PageHelperを入れたら設定でつまずいた、という話もよく聞きます。

MyBatisでのページングには大きく3つの選択肢があります。決定的な違いは DB側でLIMITされるかどうか です。この記事では手書きLIMIT/OFFSET・RowBounds・PageHelperの3方式を、発行SQLと件数の取り方、ソートの扱いを軸に比較して、そのまま動くコードと判断基準をまとめます。

Mapperの定義やXMLの基本は MyBatisの実装ガイド に譲り、ここではページングに集中しましょう。Spring Boot 3.x と mybatis-spring-boot-starter 3.x を前提にしています。

3方式をざっくり比較

まず全体像です。自分に必要な節へ直行してもらって構いません。

観点手書きLIMIT/OFFSETRowBoundsPageHelper
DB側のLIMITあり(自分で書く)なし(結果を読み飛ばす)あり(SQLを自動書き換え)
COUNTの取り方自分でCOUNT文を書く別途COUNT文が必要自動生成(カスタム可)
追加依存不要不要pagehelper-spring-boot-starter
ソートORDER BYを自分で書くORDER BYを自分で書くstartPage の引数で渡せる
DB方言自分で吸収依存しないhelper-dialect で吸収
向いているケースSQLを完全に制御したい数百件のマスタ系一覧APIが多く定型コードを減らしたい

先に結論を言うと、迷ったら手書きかPageHelper、RowBoundsは本番の大量データには使わない、です。

サンプルの前提

題材は articles テーブル(id, title, category, created_at)の一覧APIです。レスポンスDTOは JPA版のページネーション記事 と同じ形にしておくと、後でデータアクセス層を差し替えてもAPIの契約が変わりません。

public record Article(Long id, String title, String category, LocalDateTime createdAt) {}

public record PageResponse<T>(List<T> content, int page, int size,
                              long totalElements, int totalPages) {
    public static <T> PageResponse<T> of(List<T> content, int page, int size, long total) {
        int totalPages = size == 0 ? 0 : (int) Math.ceil((double) total / size);
        return new PageResponse<>(content, page, size, total, totalPages);
    }
}

ページ番号はJPA版に合わせて 0始まり で統一します。

方式1: Mapper XMLに手書きLIMIT/OFFSETで実装する

依存を増やさず、発行されるSQLを完全に把握できる一番シンプルな方式です。SELECTとCOUNTの2本立てで書きます。

<select id="findPage" resultType="com.example.Article">
  SELECT id, title, category, created_at
  FROM articles
  ORDER BY ${sort.column} ${sort.direction}
  LIMIT #{size} OFFSET #{offset}
</select>

<select id="count" resultType="long">
  SELECT COUNT(*) FROM articles
</select>

Service側でページ番号からoffsetを計算し、2つの結果を詰め替えます。size に上限を設けておかないと ?size=100000 のような要求で実質全件取得になるので、丸めておきましょう。

@Mapper
public interface ArticleMapper {
    List<Article> findPage(@Param("sort") SortSpec sort,
                           @Param("size") int size, @Param("offset") int offset);
    long count();
}

@Service
public class ArticleService {
    private static final int MAX_SIZE = 100;
    private final ArticleMapper mapper;

    public ArticleService(ArticleMapper mapper) {
        this.mapper = mapper;
    }

    public PageResponse<Article> list(int page, int size, String sort, boolean desc) {
        int safeSize = Math.min(Math.max(size, 1), MAX_SIZE);
        int offset = page * safeSize;
        List<Article> content = mapper.findPage(SortSpec.of(sort, desc), safeSize, offset);
        long total = mapper.count();
        return PageResponse.of(content, page, safeSize, total);
    }
}

LIMIT n OFFSET m はPostgreSQLとMySQLの両方で通ります。MySQL固有の LIMIT m, n 形式は移植性が下がるので避けたほうが無難です。OracleやSQL Serverでは OFFSET m ROWS FETCH NEXT n ROWS ONLY になります。

ソート列はホワイトリストで安全に扱う

上のXMLで ORDER BY ${sort.column} と書いたのが気になった方もいますよね。#{} はバインド変数なので ORDER BY 'title' という文字列リテラルになり、並び替えが効きません。列名は ${} で文字列展開するしかなく、そのままリクエスト値を渡すとSQLインジェクションの入口になります。

なので、ソート可能な列をenumで定義し、リクエスト文字列はenum経由でしか列名に変換しないようにします。

public enum SortColumn {
    CREATED_AT("createdAt", "created_at"),
    TITLE("title", "title");

    private final String property;
    private final String column;

    SortColumn(String property, String column) {
        this.property = property;
        this.column = column;
    }

    static SortColumn from(String property) {
        return Arrays.stream(values())
            .filter(c -> c.property.equals(property))
            .findFirst()
            .orElseThrow(() -> new ResponseStatusException(
                HttpStatus.BAD_REQUEST, "ソートできない項目です: " + property));
    }
}

public record SortSpec(String column, String direction) {
    static SortSpec of(String property, boolean desc) {
        return new SortSpec(SortColumn.from(property).column, desc ? "DESC" : "ASC");
    }
}

方向も boolean から固定文字列に変換しているので、${} に流れる値はすべてコード内で定義したものだけになります。不正な列名は400で弾きます。クエリパラメータの受け取り方は リクエストパラメータのバインド記事 を参考にしてください。

方式2: RowBoundsはなぜ「メモリページング」になるのか

RowBounds はMyBatis標準の仕組みで、Mapperメソッドの引数に足すだけでXMLは変更不要です。

List<Article> findAll();                     // XMLは ORDER BY だけの普通のSELECT
List<Article> findAll(RowBounds rowBounds);  // 引数に足すだけ

// 呼び出し側
List<Article> content = mapper.findAll(new RowBounds(page * size, size));

手軽なのですが、mybatis.configuration.log-impl でSQLログを出すと発行文にLIMITが 付いていない ことが分かります。

-- RowBounds(20, 10) を渡しても発行されるのはこれ
SELECT id, title, category, created_at FROM articles ORDER BY created_at DESC

MyBatisの DefaultResultSetHandler はJDBCの ResultSet をoffset分だけ読み飛ばし、limit件だけオブジェクトにマッピングして残りを捨てます。DBから見ればLIMITのないクエリなので、全行(またはfetchSize単位で順次)がアプリ側に転送されます。「全件取っている気がする」という疑念は正しいわけです。

さらに RowBounds では総件数が取れないので、COUNTクエリは結局別に書くことになります。許容できるのは、カテゴリマスタのように数百件程度でDB負荷が問題にならないケースだけと考えておきましょう。

関連設定として safe-row-bounds-enabled があります。true にするとネストした resultMapcollectionassociation)と RowBounds の併用がエラーになります。行の読み飛ばしと結合結果の組み立てが噛み合わず件数がずれるのを防ぐためで、デフォルトは false です。

方式3: pagehelper-spring-boot-starterを導入する

PageHelperはMyBatisのプラグインとしてSQLを書き換え、LIMITとCOUNTを自動で面倒みてくれるライブラリです。Spring Boot 3.x(Jakarta EE)では 2.x系 を使います。

<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>2.1.0</version>
</dependency>

Spring Boot 2.x向けは 1.4.x系 で、artifactIdは同じままバージョンだけ違うので取り違えに注意してください。

設定は application.yml に書きます。特に helper-dialect は明示しておきましょう。

pagehelper:
  helper-dialect: postgresql       # DB方言。mysql / oracle / sqlserver / h2 など
  reasonable: false                # trueだと範囲外のページ番号を自動補正する
  support-methods-arguments: false # trueだとMapper引数名 pageNum/pageSize で自動ページング
  params: count=countSql           # 引数名のマッピング。通常はデフォルトのままでOK

helper-dialect を省略するとJDBC URLから自動判定されますが、複数データソースやURLから判定できない構成では起動時や初回クエリで失敗します。明示しておいて損はありません。

実装の流れは PageHelper.startPage() → Mapper呼び出し → PageInfo でラップ、の3ステップです。PageHelperのページ番号は 1始まり なので、0始まりのAPIと合わせるなら +1 します。

public PageResponse<Article> list(int page, int size, String sort, boolean desc) {
    int safeSize = Math.min(Math.max(size, 1), MAX_SIZE);
    SortSpec spec = SortSpec.of(sort, desc);
    try {
        PageHelper.startPage(page + 1, safeSize, spec.column() + " " + spec.direction());
        List<Article> content = mapper.findAll();   // 直後のこのクエリだけが書き換わる
        PageInfo<Article> info = new PageInfo<>(content);
        return PageResponse.of(info.getList(), page, safeSize, info.getTotal());
    } finally {
        PageHelper.clearPage();
    }
}

ログを見ると、元のSELECTがLIMIT付きに書き換わり、COUNTクエリも自動で発行されているのが分かります。

SELECT count(0) FROM articles
SELECT id, title, category, created_at FROM articles ORDER BY created_at DESC LIMIT ? OFFSET ?

第3引数の orderBy はそのままSQLに連結されるので、方式1と同じホワイトリスト検証を通した値だけを渡してください。

PageHelperのハマりどころ

startPage()Page オブジェクトを ThreadLocal に保存し、次に実行されたMapperクエリがそれを消費してクリアします。この「直後の1クエリにだけ効く」仕組みを知らないと、次のような事故が起きます。

// NG: 間に別のクエリを挟むと、そちらにLIMITが付く
PageHelper.startPage(page + 1, size);
if (!categoryMapper.exists(category)) {     // ← このクエリが書き換わる
    throw new NotFoundException();
}
List<Article> content = mapper.findByCategory(category);  // 本命は全件取得になる

// OK: startPage() は本命のMapper呼び出しの直前に書く
if (!categoryMapper.exists(category)) {
    throw new NotFoundException();
}
PageHelper.startPage(page + 1, size);
List<Article> content = mapper.findByCategory(category);

もう一つ怖いのが例外です。startPage() の後、Mapper呼び出しの前に例外が起きるとThreadLocalが残ります。Tomcatはスレッドプールで動いているので、同じスレッドで次に処理される無関係なリクエストのクエリにLIMITが付く、という再現しづらいバグになります。先ほどのコードで try/finallyPageHelper.clearPage() を入れていたのはこのためです。

他にも押さえておきたい点をまとめます。

  • reasonabletrue にすると、存在しないページ番号を要求されても最終ページに補正されます。APIとして空ページを返したいなら false にしましょう。
  • 自動COUNTが重い ときは、Mapperメソッド名に _COUNT を付けたSQLを定義すると、そちらが優先されます。JOINやサブクエリを含む一覧で効きます。
  • Mapperの戻り値は List<T> にしてください。StreamOptional にすると Page に変換されず、PageInfo に総件数が入りません。
<select id="findByCategory_COUNT" resultType="long">
  SELECT COUNT(*) FROM articles WHERE category = #{category}
</select>

Spring DataのPageableに変換してJPA版と同じ形式で返す

「Controllerは Pageable で受けたい」という場合、JPAがなくても spring-data-commons だけ依存に足せば PageablePageImpl が使えます。spring-boot-starter-data-jpa は不要です。

@GetMapping("/api/articles")
public PageResponse<Article> list(
        @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
        Pageable pageable) {
    Sort.Order order = pageable.getSort().stream().findFirst()
        .orElse(Sort.Order.desc("createdAt"));
    SortSpec spec = SortSpec.of(order.getProperty(), order.isDescending());
    int size = Math.min(pageable.getPageSize(), MAX_SIZE);
    int offset = pageable.getPageNumber() * size;

    List<Article> content = mapper.findPage(spec, size, offset);
    Page<Article> result = new PageImpl<>(content, pageable, mapper.count());
    return PageResponse.of(result.getContent(), result.getNumber(),
                           result.getSize(), result.getTotalElements());
}

Sort のプロパティ名も同じ SortColumn で検証してから列名に変換しているのがポイントです。これで ?page=0&size=20&sort=createdAt,desc というJPA版と同じクエリパラメータで、同じJSONが返ります。

Page<T> をそのままJSONで返すこともできますが、Spring Data 3.3以降は PagedModel への移行が進んでいて、形式が spring.data.web.pageable.serialization-mode の設定に左右されます。安定したAPI契約にしたいなら PageResponse に詰め替えるほうをおすすめします。Pageable 自体の詳細は JPA版のページネーション記事 を見てください。

大量データではOFFSETが深くなると遅くなる

ここまでの3方式はすべてOFFSETベースです。LIMIT 20 OFFSET 100000 はDBが先頭から100,020行を読んで捨てる処理なので、ページが深くなるほど線形に遅くなります。COUNT(*) も数百万件のテーブルではそれ自体が重い処理です。

対策の一つは要件の見直しです。総件数を毎回返す必要が本当にあるか、hasNext だけで足りないかを確認しましょう。もう一つがキーセット(カーソル)ページングで、前ページ末尾の値を境界にしてWHEREで絞ります。

SELECT id, title, category, created_at
FROM articles
WHERE (created_at, id) < (#{lastCreatedAt}, #{lastId})
ORDER BY created_at DESC, id DESC
LIMIT #{size}

手書き方式ならこの形に自然に移行できます。PageHelperはOFFSET前提なので、キーセットが必要になった時点で該当APIだけ手書きに寄せる判断になります。完全な実装はこの記事のスコープ外としますが、選択肢として頭に置いておいてください。

3方式の使い分け基準

判断軸手書きLIMIT/OFFSETRowBoundsPageHelper
データ規模制限なし数百件まで制限なし(OFFSETの限界は共通)
追加依存不要不要必要
SQLの制御完全に自分で持てる持てるプラグイン任せ
複数DB対応自分で書き分け不要helper-dialect で切替
チームの規約特になし特になしThreadLocalの規約を徹底できること

依存を増やしたくない、SQLを完全に把握したい、キーセットへの移行余地を残したい、なら手書きです。一覧APIが多くてCOUNT+LIMITの定型コードを減らしたいならPageHelperが効きますが、startPage() の直前ルールと clearPage() をチームで守れることが条件になります。RowBoundsはXMLを触らずに済ませたい小さなマスタ系に限定しましょう。

なお、MyBatis-Plusを使っているなら IPage によるページングが標準なので、この記事の方式と混在させないほうがきれいです。そもそもMyBatisとJPAのどちらにするかで迷っている場合は MyBatisとJPAの比較記事 が参考になります。

まとめ

MyBatisのページングで押さえておきたいのは次の3点です。

  • RowBounds はDB側でLIMITされず、結果を読み飛ばしているだけ
  • PageHelperの startPage() は直後の1クエリにだけ効く。例外に備えて clearPage() を忘れずに
  • 手書きなら ORDER BY に渡す列名のホワイトリスト検証が必須

レスポンス形式をJPA版と揃えておけば、データアクセス層を差し替えてもAPIの契約は変わりません。一覧以外のエンドポイントも含めた実装は REST APIのCRUDチュートリアル で扱っているので、あわせて読んでみてください。