こんにちは。メルコインのフロントエンド(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]
}
次のページのリクエストが来たら、
- まず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) {
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:取得とマージ(消費件数を記録)
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さんです。引き続きお楽しみください。



