カーソルベースAPIのデータをマージするページネーション設計

こんにちは。メルコインのフロントエンド(FE)エンジニアとしてインターンをしている@nanacomです。この記事は「Merpay & Mercoin Tech Openness Month 2026」の7日目の記事です。

はじめに

インターンではFEに限らず、要件定義からバックエンド(BE)開発まで、1つのプロジェクトに幅広く取り組みました。その中で、メルコインの社内ツールを開発する際に、2つのAPIの結果を日時降順にマージして返すエンドポイントを実装するケースに直面しました。

結果を結合して並べ替えるだけならシンプルですが、マージした一覧にもページネーションを提供しようとすると、各ソースのカーソルをどこまで進めるべきかが複雑になります。本記事では、「マージ結果として採用された件数」と「各データソース側で進めるべきカーソル」のズレにどう対処したかを紹介します。具体的には、データ取得とカーソル確定を分離する「2フェーズ取得パターン」と、各ソースのカーソルを1つのトークンに束ねる「複合ページネーショントークン」の2つの設計を取り上げます。

前提:対象とするユースケース

マイクロサービスアーキテクチャでは、BFF(Backend For Frontend)で複数のサービスからデータを集約して一覧表示することがよくあります。今回対象としたのは、2つの独立したデータソース(A, B)のデータをマージするケースです。いずれも日時降順にソートされたデータを返し、それぞれがカーソルベースのページネーションAPIを提供しています。カーソルベースのページネーションとは、前回の取得結果の末尾を示すトークン(カーソル)を次のリクエストに渡すことで、続きのデータを取得する方式です。

この2つのソースの結果を日時降順にマージした一覧をクライアントに返しつつ、その一覧自体にもページネーションを提供する必要がありました。つまり、各ソースが独立して管理するカーソルを、BFF側でどう扱うかが設計上の焦点でした。

 

売買と入出金をマージした一覧表示(※表示データはすべてダミーです)

素朴なアプローチとその限界

この設計上の焦点に対して、私たちはまず2つの素朴なアプローチを検討しました。いずれも限界があり、最終的な設計への動機となりました。

アプローチ1:全件取得してソート

最も単純な方法は、両ソースから全件を取得し、アプリケーション側でソートしてからページごとに切り出す方法です。しかし、データ数が増えるとメモリ使用量とレイテンシーが線形に増加するため、スケールしません。

アプローチ2:各ソースからpageSize件取得してマージ

各ソースからそれぞれ pageSize 件を取得し、マージして上位 pageSize 件を選択する方法です。データ取得量を抑えられるため現実的ですが、ここで1つの問題が発生します。

例として pageSize=5 のとき、Aから [A1..A5]、Bから [B1..B5] が返ってきたとします(いずれも日時降順)。これらをマージして上位5件を作ると、マージ結果に含まれるのがAから3件(A1,A2,A3)、Bから2件(B1,B2)になるとします。

次のページでは本来、AはA4から、BはB3から取得を再開する必要があります。しかし各ソースAPIが返すカーソルは「返却リスト末尾の次」を指すため、手元のカーソルはA6(= Aを5件進めた次)やB6(= Bを5件進めた次)を指してしまいます。マージ結果に必要な再開位置(A4/B3)と、手元のカーソル(A6/B6)が一致しません。

(図1)各ソースから取得 (pageSize=5)

Source A: [A1][A2][A3][A4][A5] -> cursorA = A6
Source B: [B1][B2][B3][B4][B5] -> cursorB = B6

マージして上位5件を採用すると、実際に消費したのは Aが3件 / Bが2件 になります(採用: A1 A2 A3 / B1 B2)。

このとき次ページで「本当に再開したい位置」と「手元のカーソル」がズレます。

ソース 次ページで本当は 手元のカーソル
A A4 から再開 A6 を指す
B B3 から再開 B6 を指す

これが、本記事で解決する核心的な課題です。次のセクションでは、この課題に対して理想的にはどう解決すべきかを考え、そのうえで私たちが採った設計方針を説明します。

理想の解決策と現実の制約

カーソルベースAPIでは、返却件数とカーソルの進行量が常に一致します。pageSize=5 でリクエストすれば5件返り、カーソルも5件分進みます。しかし今回のように複数ソースのデータをマージするケースでは、5件取得しても実際に採用するのは一部だけです。この「取得件数」と「消費件数」のズレが根本原因です。

仮に各ソースのAPIがカーソルではなくタイムスタンプによる範囲指定をサポートしており、かつソース内のタイムスタンプが一意であれば、この問題は発生しません。例えば、以下のように、マージ結果で最後に消費したアイテムの日時を基準に次ページを取得できます。

GET /orders?before=2025-01-01T10:00:00Z&limit=5
GET /transfers?before=2025-01-01T10:00:00Z&limit=5

この方式であれば、各ソースの消費済み最終タイムスタンプを1つのトークンに含めるだけで、BFF側に状態を持たずに1回のリクエストでページネーションを実現できます。また before で過去方向に切るため、新しいデータが追加されてもページ跨ぎの重複が起きません。

しかし、各マイクロサービスのAPI仕様を変更するのは現実的ではないため、既存仕様のままBFF層で解決する方法を検討しました。

設計方針の決定

BFF層での解決策として、トークンへの情報埋め込み、サーバー側キャッシュ、データ取得とカーソル確定の分離という3つの方法を検討しました。設計のシンプルさとステートレス性を重視した結果、3つ目の「2フェーズ取得」方式を採用しました。

方法1:トークンに情報を詰め込む(拡張複合トークン)

各ソースのカーソルを1つのトークンに束ねて返す際に、カーソルだけでなく、次ページを再開するために必要な情報をまるごとトークン内に埋め込む設計です。例えば「Aから何件/Bから何件消費したか」のようなメタ情報も含め、JSONにまとめてBase64エンコードして返します。

{
  "cursorA": "abc123",
  "cursorB": "def456",
  "consumedA": 3,
  "consumedB": 2
}

この方式だと、クライアントが次のリクエストでトークンをそのまま返すことで、サーバーはトークンをデコードするだけで「次ページの再開位置(A4/B3など)」を復元できます。

しかし、既存のソースAPIがカーソルベースの仕組みを提供している中で独自にオフセット等も管理すると、「カーソルの意味」が二重になり設計が複雑化するため、採用しませんでした。

方法2:Redisなどで「使わなかったデータ」を保持する(サーバー側キャッシュ)

各ソースから pageSize 件ずつ取得してマージした結果、採用されなかった"余り"のデータ(例:A4, A5 / B3, B4, B5)をサーバー側で保持しておく設計です。例えばユーザー(またはリクエスト)単位のセッションキーでRedisに格納します。

session:user123 → {
  unusedA: [A4, A5],
  unusedB: [B3, B4, B5]
}

次のページのリクエストが来たら、

  1. まずRedisに残っているデータを先に使ってマージし
  2. 足りない分だけ各ソースAPIから追加取得する

という流れにすれば、カーソルのズレ問題を回避できます。

しかし、サーバー側に状態を持つことになり、社内ツールの規模に対してインフラの運用コストが見合わないため、採用しませんでした。

方法3(採用):データ取得とカーソル確定を分離する(2フェーズ取得)

上記2つの方法では、1回のAPI呼び出しでデータ取得とカーソル確定を同時に済ませようとしています。発想を変え、データを取得してマージするフェーズと、消費件数に基づいてカーソルを確定するフェーズを分けることで、この問題を解決します。サーバーはステートレスのまま、既存APIの仕組みをそのまま活かせます。API呼び出し回数は増えますが、最もシンプルな設計です。

許容するトレードオフ

ただし、この方式では2回のAPI呼び出しの間に多少の時間差が生じます。そのわずかな間に対象データが追加された場合、次ページに重複したデータが現れる可能性があります。

私たちはこの問題を、以下の理由から許容可能なトレードオフと判断しました。

  • 影響は「ページを跨ぐ際の重複表示」に限定される
  • 対象がリアルタイムに頻繁に更新されるデータではないため、発生頻度は低い
  • 完全な整合性を保証するには、各ソースのAPI仕様変更が必要になり、コストに見合わない

この判断のもと、以降のセクションで方法3の具体的な実装を説明します。

2フェーズ取得パターン

前のセクションで述べた方法3を、具体的にどう実装したかを説明します。データを取得してマージするフェーズと、消費件数に対応するカーソルを確定するフェーズに分けて設計しました。

フェーズ1:取得とマージ

  1. ソースA、ソースBからそれぞれ pageSize 件を並行して取得する
  2. 日時降順でマージし、合計 pageSize 件を取り出す
  3. ソースAとソースBそれぞれで、実際に消費した件数を記録する

この処理は、Go の container/heap を使ったストリーミングマージとして実装できます。各ソースの先頭要素をヒープに入れ、日時が最も新しいものを1つずつ取り出しながら pageSize 件を集めます。以下のコードのとおり、各ソースのインデックス(indexA, indexB)がそのまま消費件数を表します。

func Merge(pageSize int32, itemsA, itemsB []*Item) ([]*Item, int32, int32) {
    indexA, indexB := 0, 0
    result := []*Item{}

    h := &timeHeap{}
    heap.Init(h)
    if len(itemsA) > 0 {
        heap.Push(h, &record{source: SourceA, time: itemsA[0].Timestamp})
    }
    if len(itemsB) > 0 {
        heap.Push(h, &record{source: SourceB, time: itemsB[0].Timestamp})
    }

    for h.Len() > 0 && len(result) < int(pageSize) {
        r := heap.Pop(h).(*record)
        switch r.source {
        case SourceA:
            result = append(result, itemsA[indexA])
            indexA++
            if indexA < len(itemsA) {
                heap.Push(h, &record{source: SourceA, time: itemsA[indexA].Timestamp})
            }
        case SourceB:
            result = append(result, itemsB[indexB])
            indexB++
            if indexB < len(itemsB) {
                heap.Push(h, &record{source: SourceB, time: itemsB[indexB].Timestamp})
            }
        }
    }

    return result, int32(indexA), int32(indexB)
}

戻り値の indexAindexB が、フェーズ2でカーソルを正確に進めるための入力になります。

フェーズ2:カーソルの確定

  1. ソースA、ソースBそれぞれにおいて、フェーズ1と同じ開始位置から消費件数分だけ再取得し、進んだ位置のページネーショントークンを取得する(cursorA, cursorB
  2. pageTokencursorA:cursorB(参照:次のセクション)とすることで、次ページの取得時に正しい位置からデータを取得できる

なお、一方のソースのデータがもう一方より古い場合など、フェーズ1でデータが返ってきたにもかかわらずマージで1件も採用されないケースがあります。この場合は、そのソースのカーソルを前回の位置のまま保持し、次ページのリクエストで再び同じデータを取得してマージの対象にします。また、フェーズ1でデータが0件だった場合は、そのソースを枯渇と判定し、ターミナルトークン _ を設定します。

(図2)フェーズ1:取得とマージ(消費件数を記録)

Source A ──(pageSize件)──┐
                         ├→ Merge → Top N
Source B ──(pageSize件)──┘
                    │
              消費件数を記録
              (A=3件, B=2件)

(図3)フェーズ2:カーソルの確定(消費件数分だけ進める)

Source A ──(消費3件)──→ cursorA
Source B ──(消費2件)──→ cursorB
→ 複合トークン: "cursorA:cursorB"

複合ページネーショントークン設計

2フェーズ取得パターンにより、各ソースで消費件数分だけ進んだカーソルを取得できるようになりました。次に、これらのカーソルをクライアントにどのように渡すかを設計します。今回の一覧取得APIでは、pageToken を各ソースのカーソルを結合した複合トークンとして設計します。

"cursorA:cursorB"

片方のソースが完全に尽きた場合は、ターミナルトークン _ で表現します。トークンがターミナルトークン _ だった場合、API呼び出しをスキップできます。これにより、初回リクエストから片方のソースが枯渇した状態まで、以下のようにページネーショントークンで表現することができます。

トークン 意味
"" (空文字) 初回リクエスト
"cursorA:cursorB" 両ソースとも継続あり
"_:cursorB" ソースAは枯渇、Bのみ継続
"cursorA:_" ソースBは枯渇、Aのみ継続
"_:_""" に変換 全データ取得済み(次ページなし)

この複合トークンと2フェーズ取得パターンを組み合わせることで、サーバー側に状態を持たずに、マージした一覧のページネーションを実現できます。

まとめ

本記事では、カーソルベースAPIを持つ複数データソースから一覧を構築する際に直面した「マージで実際に消費した件数」と「APIが返すカーソル位置」のズレという課題と、その解決策を紹介しました。

最初は1回のAPI呼び出しで全てを済ませようとしていましたが行き詰まり、「データを取得するフェーズ」と「カーソルを確定するフェーズ」に分離することで解決できました。1つの処理が複数の責務を担って複雑になったとき、フェーズを分けて各ステップの役割を単純化するアプローチは、ページネーションに限らず設計全般で有効な考え方だと感じています。

このような設計上のトレードオフを実際に手を動かしながら考えられたのは、インターン期間中の貴重な経験でした。FEに限らず幅広く関わらせていただいたことに感謝しています。本当にありがとうございました!

次の記事は@mikupoさんです。引き続きお楽しみください。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加