AIニュース最前線
最新ニュースAI日報Hacker日報週報動画AIツールトレンド企業

AIニュース最前線

世界中のAI最新情報を日本語で毎時更新

最新ニュース日報トレンド企業プレミアムRSS
© 2026 ainew.jp特定商取引法に基づく表記
ニュース一覧元記事を開く
Mercari Engineering·2026年6月10日 11:00·約13分で読める

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

#マイクロサービス#API デザイン#ページネーション#システムアーキテクチャ
TL;DR

メルコインのエンジニアが、複数ソースからのカーソルベースページネーションをマージする際の複雑な課題に対し、取得とカーソル確定を分離する設計パターンを提案している。

AI深層分析2026年6月10日 02:02
3
注目/ 5段階
深度40%
4
関連度30%
2
実用性20%
5
革新性10%
3

キーポイント

1

マージページネーションの根本的課題

各データソースから固定件数(pageSize)を取得してマージした場合、採用された件数がソースごとの取得カーソルと一致せず、次ページの再開位置が特定できない問題が発生する。

2

素朴なアプローチの限界

全件取得によるソートはメモリとレイテンシーの観点からスケールせず、単純に各ソースから pageSize 件ずつ取得する手法ではカーソルのズレを解消できない。

3

2 フェーズ取得パターンの提案

データ取得フェーズとカーソル確定フェーズを分離し、マージ結果として採用された件数に基づいて各ソースのカーソル位置を正確に算出する手法を採用した。

4

複合ページネーショントークンの実装

複数のデータソースが持つ個々のカーソル情報を、1 つのトークンに束ねる「複合ページネーショントークン」を設計し、クライアントへの返却と次リクエストでの復元を実現した。

5

マージアルゴリズムの採用

複数のカーソルベースAPIからデータを取得する際、最小ヒープ(min-heap)を用いて時系列順に効率的にマージする設計が示されています。

6

ページネーション制御の実装

結果配列の長さが指定されたページサイズ(pageSize)に達するか、ヒープが空になるまでループを継続することで、正確なページングを実現しています。

7

フェーズ分離によるカーソル確定

マージ処理で実際に消費した件数に基づき、各ソースのカーソルを再取得する「フェーズ2」を設けることで、API の返すカーソル位置と実際の消費数のズレを解消します。

影響分析・編集コメントを表示

影響分析

この記事は、大規模マイクロサービス環境における BFF レベルのデータ集約処理において、パフォーマンスと整合性を両立させるための具体的なアーキテクチャパターンを示しています。特にカーソルベースページネーションを扱うシステム開発者にとって、実装上の落とし穴とその解決策を知る上で非常に実践的な知見を提供しており、類似ユースケースを持つエンジニアリングチームの設計品質向上に寄与します。

編集コメント

AI 技術そのものに関するニュースではありませんが、大規模データ処理における 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 側でどう扱うかが設計上の焦点でした。

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

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

この設計上の焦点に対して、私たちはまず 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]

}

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

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

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

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

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

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

許容するトレードオフ

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

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

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

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

2 フェーズ取得パターン

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

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

  • ソースA、ソースBからそれぞれ pageSize 件を並行して取得する
  • 日時降順でマージし、合計 pageSize 件を取り出す
  • ソース 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) {

item := heap.Pop(h).(*record)

result = append(result, item.item)

if item.source == SourceA {

indexA++

} else {

indexB++

}

}

return result, indexA, indexB

}

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

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

  • ソース A、ソース B それぞれにおいて、フェーズ 1 と同じ開始位置から消費件数分だけ再取得し、進んだ位置のページネーショントークンを取得する(cursorA, cursorB)
  • pageToken を cursorA: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 さんです。引き続きお楽しみください。

原文を表示

こんにちは。メルコインのフロントエンド(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)

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

code
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エンコードして返します。

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

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

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

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

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

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

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

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

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

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

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

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

許容するトレードオフ

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

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

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

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

2フェーズ取得パターン

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

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

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

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

code
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)
}

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

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

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

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

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

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

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

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

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

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

code
"cursorA:cursorB"

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

トークン

意味

"" (空文字)

初回リクエスト

"cursorA:cursorB"

両ソースとも継続あり

"_:cursorB"

ソースAは枯渇、Bのみ継続

"cursorA:_"

ソースBは枯渇、Aのみ継続

"_:_" → "" に変換

全データ取得済み(次ページなし)

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

まとめ

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

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

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

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

この記事をシェア

関連記事

InfoQ2026年4月3日 18:00

100以上のサービスを停止させずにデータベースシーケンスを大規模に置き換える

Saumya Tyagiが、多数のサービスに影響を与えずにデータベースシーケンスを大規模に置き換える方法について説明している。

Mirai Translate Tech Blog★32025年10月17日 12:00

レガシーデータを安全にモダナイズする──ストラングラーフィグパターンとサーガパターンによる用語集管理システムのリアーキテクチャ

レガシーな用語集管理システムを、ストラングラーフィグパターンとサーガパターンを活用して段階的にモダナイズし、データ整合性を保ちながら安全に移行する実践例を解説。

InfoQ★32026年4月13日 20:00

SpringチームがSpring Framework 7とSpring Boot 4について語る

InfoQがSpringチームの主要メンバーにインタビューし、Spring Framework 7とSpring Boot 4のアーキテクチャ・機能面での進展について聞いた。フレームワークにリトライや並行性スロットリングを組み込み、コアレジリエンスへの戦略的転換を図っている。

今日のまとめ

AI日報で今日の重要ニュースをまとめ読み

ニュース一覧に戻る元記事を読む