TECH PLAY

株式会社メルカリ

株式会社メルカリ の技術ブログ

292

こんにちは。メルペイ Director の @abcdefuji です。この記事は「 Merpay & Mercoin Tech Openness Month 2026 」の 20日目の記事です。 はじめに 私たちは、決済プラットフォームチームに Slack メンションで起動し Google Compute Engine(GCE) 上に常駐する AI エージェント pcp-agent を作り、本番運用しています。狙いは「人間が AI をキックする」ツールではなく、トリガーベースで自律実行する Ambient Agent を作ることでした。 本記事では、この取り組みで本当に難しかった2点、決済ドメインで自律エージェントを安全に動かすセキュリティと、その効果を計測する仕組みについて実コードと図で解説します。さらに、これが単一のbotではなく、SREやn8nなど複数ドメインへ展開する再利用可能なエージェント基盤(remote-claude)であることも紹介します。 背景:なぜ「常駐エージェント」なのか PCP(Payment & Customer Platform)は数十のマイクロサービスを抱え、社内の大量のユースケースを支えています。障害調査、アラートのトリアージ、システム間の金額検証、問い合わせ対応。これらはいずれも「決まった手順だが人手を食う」作業です。私たちはこうした運用を AI に肩代わりさせたいと考えました。 掲げたビジョンはシンプルです。AI があらゆるオペレーションをトリガーベースで自律実行し、人間のアクションを必要としない Platform 体制を構築する、というものです。「人間が AI をキックする」世界観は前提から外しました。 だからこそ私たちは、チャット UI に個人が質問を投げるツールではなく、チームに常駐し、トリガーで自律的に動く Ambient Agent として pcp-agent を位置づけました。人で言えば「決済プラットフォームチームに運用担当が常駐している」イメージです。 全体アーキテクチャ pcp-agent は Slack 統合型の Claude Code bot で、隔離された Docker コンテナ内で動きます。セッションごとに copy-on-write ファイルシステムで独立したコンテナが立ち上がり、影響範囲をセッション単位に閉じ込めます。共通インフラ(server / scheduler / auth-proxy)が Slack メンションやスケジューラーからの起動を受け、コンテナを起動します。 エージェントの権限管理関しては、決定的な層を導入しています。書き込み可能なリポジトリは、社内の集中管理された ACL と、そこから発行される scope を絞った短命の GitHub token によって リポジトリ単位で機械的に制限しています。それ以外はすべて read-only(調査・参照用)です。CLAUDE.md には writable repo を明示してエージェントの振る舞いを誘導しますが、強制するのは上記の決定的な層です。リスクが高い領域への書き込みはエージェントに許さない、という境界をまず引きました。 仕組み:Claude Code を核に remote-claude で包む 中身を一段掘り下げます。pcp-agent の心臓部は Claude Code そのものです。私たちは Claude Code を「コマンドラインで動くエージェントランタイム」として使い、その周りを remote-claude という薄いラッパーで包んでいます。remote-claude は Slack やスケジューラーからのトリガーを受け取り、セッションごとに Docker コンテナを立て、その中で Claude Code を起動し、標準出力を Slack に橋渡しする役割を担います。 Claude Code は単体では汎用のコーディングエージェントです。これを「決済プラットフォームドメインの運用担当」に仕立てているのは、ワークスペースに置いた .claude ディレクトリの中身です。ここには振る舞いを決める CLAUDE.md、ドメイン知識と手順をコード化した Skill / Plugin、そして実行のたびに介入する Hook が含まれます。能力を増やしたいときはモデルを差し替えるのではなく、スキルやルールを足す、つまり「プロンプトとファイル」で拡張するのが設計の基本方針です。 一回の実行(セッション)の流れはこうです。トリガーが来ると remote-claude が新しいコンテナを copy-on-write で起こし、その中の Claude Code が Slack メッセージやスケジュールされたプロンプトを入力として受け取ります。エージェントがツールを呼ぶたびに、まず PreToolUse hook がセキュリティ検査をかけて危険なら止め、ツール実行後に PostToolUse hook が 開発者体験の計測プラットフォームであるDX へ利用イベントを送ります。同じセッション(Slack スレッド)への追加リクエストは、起動中の Claude Code プロセスを再利用するため高速で、コールドスタート約30秒に対してウォームは約2〜3秒です。セッションはアイドルが約30分続くとクライアントを切断し、約1週間(session_ttl)で破棄します。各セッションは copy-on-write で独立した workspace を持つので、影響範囲をセッション単位に閉じ込めつつ再現性を確保できます。 設計上の要点は「LLM を信用しすぎない」ことです。エージェントの判断(自然言語)は本質的に非決定的なので、安全性や監査が要る部分は決定的な Hook、ネットワークレベルの auth-proxy、コンテナ隔離、IaC 管理のシークレットなど、LLMの外側に寄せています。LLM には「何をやりたいか」を任せ、「やってよいか」「どう繋ぐか」はコードと基盤で固める、という責務分離です。 認証設計:auth-proxy でクレデンシャル注入を一点集中 エージェントは Notion / Jira / DX / PagerDuty / Datadog / Slack など多数の外部 API を横断します。認証方式はサービスごとにバラバラで、これを LLM に直接持たせるのは危険です。そこで私たちは auth-proxy をネットワークレベル(iptables DNAT)に置き、宛先ごとに適切な認証情報を自動注入する構成にしました。注入するヘッダや取得方法はサービスごとに異なり(環境変数、keyless な GitHub トークン、GCP サービスアカウントのインパーソネーションなど)、その差異を auth-proxy が吸収します。エージェント側の curl はトークンを一切知りません。 この設計のもう一つの利点は、シークレットをエージェントのコードから完全に分離できることです。実トークンはすべて IaC(microservices-terraform)で管理し、auth-proxy 側にだけ環境変数として渡します。ランナー(エージェント実行コンテナ)に埋め込まれるのは非機能のダミー値だけで、万が一漏れても無効です。エージェントのワークスペースにクレデンシャルは存在しません。 セキュリティ:決済ドメインで自律エージェントを動かすための多層防御 決済ドメインで本番権限を持つ自律エージェントは、最高レベルの安全要求を満たす必要があります。pcp-agent はdeny rules、PreToolUse hook、behavioral rules、auth-proxy、コンテナ隔離の5層で防御しています。重要なのは、単一の層に依存しないことです。どれか一つが破られても、別の層が止める設計にしています。 主防御線に据えたのは PreToolUse hook です。個々のツールごとの許可・拒否設定に頬り切るのではなく、どのツール経由でも危険な操作を止められる決定的なフックを最終的な強制層に置く方が確実だと考えたからです。後述するように、このフックは想定外の終了コードで落ちた場合に「黙って許可」ではなく「ブロック」を返す fail-closed 設計にしてあります。フックがクラッシュした隙にコマンドが通る事故を防ぐためです。 シークレットファイルの検出には現実的な難しさがあります。jq のフィールドアクセスをしてissue key を取得するような正当な操作と、秘密ファイルの読み取りを区別しなければなりません。私たちのパターンはファイルパスとして現れる鍵ファイルのみを止め、クエリ構文は通すよう練られています。 この手の防御は「イタチごっこ」です。セキュリティレビューを重ねるたびに想定外の抜け道が見つかり、その都度検出パターンを強化してきました。正当な操作は通しつつ、秘密情報の読み取りだけを確実に止める。その考え方を、次の小節でコード例とともに示します。 設計判断(ADR)も明文化しています。たとえば WebSearch と WebFetch はドメイン許可リストの対象外としました。許可リストは「LLM が Bash コマンドで URL をハルシネートする」ことを防ぐためのもので、Web 閲覧(読み取り)とは脅威モデルが異なるからです。ただし取得 URL は秘密パターンをスキャンし、クエリ経由のデータ漏洩を防いでいます。また Codex MCP は本エージェントでは有効化していません。Codex は独自のシェル実行エンジンを持ち Claude Code の deny rule を迂回するため、human-in-the-loop のない Slack bot では受け入れ不可と判断し、フックでブロックしています。 PreToolUse hook:fail-closed の安全網 #!/usr/bin/env bash # PreToolUse security hook — deterministic enforcement layer # Exit codes: 0 = allow, 2 = block set -euo pipefail # 0=allow / 2=block 以外で落ちたら block を返す(クラッシュ時に許可しない) trap 'ec=$?; if [[ $ec -ne 0 && $ec -ne 2 ]]; then echo "{ decision: block, reason: hook failed (exit $ec) — blocking for safety }" exit 2 fi' EXIT 注:上記の echo は可読性のため簡略表記にしています。実装では decision / reason を含む JSON を出力します。 シークレットファイル検出のパターン 正当な jq フィールドアクセスは通しつつ、鍵ファイルの読み取りだけを止めます(一部のパスは可読性のため簡略表記)。 secret_file_patterns=( '.env(boundary)' '.env.' '<pathchar>.pem(boundary)' '<pathchar>.key(boundary)' 'credentials.*.json' '/proc/<pid>/environ' ) # .env.example / process.env.X / jq の .fields.parent.key は許可(ファイル読みではない) ポイントは boundary(境界)と pathchar(パス文字)の条件で「ファイルパスとして現れる鍵ファイル」だけを対象にし、 { key: .key } のようなオブジェクトアクセスを誤検知しないことです。 計測:PostToolUse hook × DX で効果を測る 「導入したが効果は?」に答えられなければ本番運用は続きません。私たちは、エージェントのツール実行を PostToolUse hook で捕捉し、DX にイベント送信しています。これにより「どのスキルやツールが、誰に、どれだけ使われたか」を継続的に計測できます。 設計には三つのポイントがあります。第一に fail-open です。計測はエージェントの動作を妨げてはいけないので、どんなエラーでも黙って正常終了します。セキュリティの PreToolUse が fail-closed なのとちょうど対照的で、層ごとに設計思想を逆にしているのが要点です。第二にセッション内重複排除で、同じイベント型は1セッションに1回だけ送ります。第三にメール解決で、Slack コンテキストからユーザーを特定し、bot やスケジュール実行ではフォールバックの共通アドレスに振り替えます。 興味深いのは、送信するだけでなくエージェント自身が DX のメトリクスを読むことです。チームスコアは MCP 経由で直接クエリし、Core4(Effectiveness / Impact / Quality / Speed)や sentiment を参照します。これにより、エージェントが自分の置かれたチームの開発者体験データを踏まえて振る舞える素地ができます。なお、コメント API は個人のメールを含むため、リポジトリに保存せず Slack にも出さず、必要時にオンデマンドで取得して 1on1 準備などの参照にのみ使う、という PII 取り扱いを徹底しています。 フロー:PostToolUse hook の処理 これによりダッシュボード側でスキル別・ツール別の利用状況を集計できます。 エージェントに「記憶」を持たせる:ナレッジの自動メンテナンス ドメイン知識は Slack と Notion に散在し、放置すればエージェントは古い情報で答えてしまいます。私たちはこれをスケジュールジョブで継続的に更新しています。日次のチャンネルナレッジ抽出ジョブ(対象チャンネルの整備後に本格有効化予定)は、前日の人間同士の会話を読み、ドメインに関わる知識をスキルやナレッジとして抽出・提案します。 さらに、エージェントが対応した Slack スレッドを見直して有用な知識を登録し、既存エントリの更新・削除を整理する LangMem ベースの自動メンテナンスも回しています。RAG で都度検索するだけでなく、記憶そのものを継続的に手入れする発想です。 加えて「AI が読みやすいドキュメント構造」への投資も行いました。リポジトリ一覧や監視チャンネル一覧を機械可読な形で同期し、エージェントが参照しやすくしています。PCPにはリポジトリだけで約50個ほどあるのでこのような情報を整理することがスタートでした。知識の鮮度・ノイズ・ハルシネーションは依然として課題ですが、人手の更新に頼らない仕組みを基盤として持てたことが大きな前進でした。 自律スケジューラー:何が毎日・毎週動いているか pcp-agent は Slack メンションでの起動だけでなく、cron ベースのスケジューラーで自律的にも動きます。これが「トリガーベースで人間のアクションを必要としない」というビジョンの実装そのものです。週次のダッシュボード要約やアラートダイジェストは、誰かが依頼しなくても毎週決まった時刻に投稿されます。 ジョブ定義は宣言的な YAML で、タイムゾーン・タイムアウト(active_deadline_seconds)・リトライ(backoff_limit)・多重起動抑止(concurrency_policy: forbid)・出力先 Slack チャンネルを指定できます。中心は「PCP Daily Schedule」で、平日毎朝9時(JST)に4つのルーティンを順番に実行し、各完了後に中間結果を Slack に投稿、最後に「本日のまとめ」を出します。当初は別々のジョブだったものを、一つの統合ワークフローに集約しました。このループを回すこと自体で、日々の会話からナレッジが溜まり、スキルも更新されていく、そうした自己更新的なループとして設計しています。 スケジュール例: ジョブ スケジュール 内容 pcp-daily-schedule 平日 9:00 4タスク統合:①リリースノート生成 ②対象リポジトリ追跡(新スキル取込提案)③既存スキル改善提案 ④LangMem ナレッジ前日分メンテ xxxx-weekly-dashboard-summary 毎週月 10:30 各チームダッシュボードの週次サマリ yyyy-weekly-alert-digest 毎週月 11:00 各チームアラートの週次ダイジェスト - name: xxxx-weekly-alert-digest enabled: true cron: "0 11 * * 1" # Every Monday 11:00 (JST) prompt_file: "config/prompts/xxxx-weekly-alert-digest.md" timezone: "Asia/Tokyo" active_deadline_seconds: 600 # 10分でタイムアウト backoff_limit: 1 # 失敗時に1回リトライ concurrency_policy: forbid # 前回実行中ならスキップ ポイントは、スケジューラーが単なる cron 以上であることです。タイムアウト、リトライ、多重起動抑止といったジョブ制御を持たせることで、自律実行を本番で安全に回せるようにしています。 実際のユースケース pcp-agent の能力はスキルとしてファイル化され、実運用で次のようなユースケースに使われています。いずれも「決まった手順だが人手を食う」調査・集計・トリアージ作業です。 ユースケース スキル 内容 障害・アラート調査 investigate-alert アラートを起点に関連ログ・メトリクスをたどり一次切り分け 日次トリアージ xxxx-daily-triage XXXXチームの毎日の異常検知とトリアージ 問い合わせ調査 investigate-inquiry 問い合わせをドメイン知識付きで調査 週次サマリ yyyy-weekly-alert-digest 各チームのアラート・ダッシュボードの週次要約 PR監視・Issue起票 watch-pr / issue / jira レビュー補助と起票 この中でも最初のわかりやすいユースケースは調査のユースケースでした。 参考例として、Alert(トリガー) → Datadog(データ集約) → Slack / Notion等 (データ可視化・レポート) という流れです。すでに多くのエンジニアのローカル環境にはデータ調査スキルが存在すると思います。そのスキルをリモートに持ってきてトリガーさせるだけで一定の時間削減が実現でき、そしてローカルに存在するスキル(属人性)をリモートに持ってくることで誰でも同じ体験を実現することができます。 そして、これらのユースケースが「実際にどれだけ使われているか」を DX で可視化しています。どのユースケース(スキル)が、どのチームで、どれくらい使われたかをダッシュボードで追跡できます。エージェントの価値を「何ができるか(能力)」ではなく「何に使われているか(採用)」で測るのが狙いです。 プラットフォームとしての強み:1つのbotではなく、エージェント基盤 ここまで pcp-agent という1つのエージェントを見てきましたが、本当の狙いは個別のbotを作ることではありません。私たちが作ったのは remote-claude という再利用可能なプラットフォームで、pcp-agent はその上で動く1テナントにすぎません。同じ基盤の上に、SRE 向けの remote-claude-sre、ワークフロー自動化(n8n)向けの remote-claude-n8n が並んで動いており、data platform 領域へも展開しています。 プラットフォームと各エージェントの責務は明確に分かれています。共有基盤(remote-claude)が提供するのは、Slack統合、セッション隔離(copy-on-write)、cronスケジューラー、auth-proxy、PreToolUse/PostToolUseフックの仕組み、PIIリダクション、ランナーのリソース制限(メモリ・PID・CPU)といった「どのエージェントにも共通して必要な土台」です。各テナントが持つのは、自分のドメインに固有の workspace/.claude、つまりCLAUDE.md、スキル、ルールだけです。 この設計の効きどころは、新しいエージェントを立ち上げるコストの小ささです。共有設定は base.yaml に集約され、各エージェントは差分だけを上書きします。テンプレート(remote-claude/template)をコピーし、ドメイン知識をスキルとして足せば、Slack起動・隔離・スケジューラー・セキュリティ・計測がすべて最初から揃った状態で新しいテナントが立ち上がります。「決済の常駐エージェント」で作った仕組みが、そのまま他のチームにも転用できるわけです。 # remote-claude/config/base.yaml (全エージェント共通、各エージェントが上書き) default_model: sonnet use_proxy_network: true # ランナーに権限を与えずオーバーレイで経路制御 session_policy: session_ttl_minutes: 10080 # 7 days runner_memory_limit: "4g" runner_pids_limit: 256 runner_idle_timeout_minutes: 30 slack_output_redaction: false # PIIリダクションをデプロイ単位でONにできる セキュリティや計測のような「外側で固める」部分をプラットフォーム側に持たせることで、各チームはドメイン知識の記述に集中でき、かつ全社的に一貫した安全性・可観測性を担保できます。これがエージェントを「点」ではなく「基盤」として広げる強みです。ちなみに remote-claude はもともと SRE 向け(cc-sre)に生まれ、そこから複数ドメインをホストする汎用プラットフォームへと一般化されていきました。 まとめ 半年運用して、いくつか確かな手応えがありました。まずスキル化は効きます。ドメイン知識を SKILL.md としてコード管理すると、エージェントの振る舞いが再現可能になり、レビューやテストの対象にできます。エージェント自身に新しいスキルを作らせるループも回り始めました。 もっとも、正直なところ最大の課題はユースケースの深掘りだと感じています。問い合わせのための調査のように、各所のパーツは AI に代替できる部分が出てきましたが、まだ人間がトリガーしている場面が多く、真に AI を軸に置いたフローになっているとは言えません。エージェントが受け取れるトリガーをさらに増やし、ユースケース全体を「AI 前提」のプロセスへ置き換えていきたいと考えています。 今後は、このエージェントを一つに統合するか、ドメインごとに分散させるかをユースケース次第で見極めていきます。LLM のコンテキストは有限なので、独自のワークフローが必要なユースケースが増えれば分散させる方針です。 加えて、社内でこうしたエージェントが増えていくほど、個々の精度以上に「フリート全体をどう統治し、どう観測するか」が重要になります。エージェントごとに権限・ポリシー・監査がバラバラだと、事故も非効率も増えていきます。そこで、ガバナンスと各エージェントの振る舞い・コスト・利用状況などを横断的に把握する仕組みを、今後プラットフォーム側で整えていきたいと考えています。 「AI エージェントを作ってみた」記事は数多くありますが、決済ドメインの本番チームに常駐させ、セキュリティを多層で固め、効果を計測しながら回すところまでやると、考えるべきことは一気に増えます。私たちが半年で取り組んだのは、その「面倒な部分」を実コードで埋めてきた記録です。そして、その面倒な部分を共有基盤(remote-claude)に寄せたことで、決済で作った仕組みを SRE や n8n へ横展開できる「エージェント基盤」に育ちつつあります。これから業務にエージェントを組み込む方にとって、安全性と計測をプラットフォーム側でどう設計するかの一例になれば幸いです。
こんにちは becosuke です。メルカリ NFT と、その上で立ち上げている新規サービスの Backend を担当しています。この記事は「 Merpay & Mercoin Tech Openness Month 2026 」の 20日目の記事です。 この記事では、私たちメルカリ NFT チームがこの4ヶ月ほどで取り組んできた AI-Native な開発について書きます。1月末から、開発のやり方そのものを大きく作り変えてきていて、最終的には人の手をできるだけ介さずにサービスを作り続けられる状態を目指しています。その過程で得た学びを、似たことに取り組もうとしている方に持ち帰ってもらえたらと思っています。 先に結論だけ書いておきます。私たちがリソースを集中させるべきだと判断したのは「正解」そのものではなく、何が正しく何が誤りかの「判断基準」( 注1 )でした。AI を動かすための機構は、これからどんどん外から手に入るようになります。けれど判断基準だけは、その案件ならではの価値観そのものなので、外注できません。だから私たちは、機構を作ることよりも、その上に載せる判断基準を残すことに時間をかけました。なぜそう考えたのか、そして実際に何をやったのかを、順を追って書いていきます。 この案件の位置付け 私たちは、Non-Fungible Token(NFT)の売買を行うマーケットプレイスである メルカリ NFT を既存サービスとして運営しています。いま新しく立ち上げているのは、この既存の メルカリ NFT のコードベースを土台にした新規サービスです。 この案件には、2つの顔があります。1つは、新しいプロダクトの立ち上げであること。もうひとつは、AI-Native な開発のあり方そのものを試す Proof of Concept(PoC)の場であることです。 メルカリには会社全体として AI-Native 化を進める方針があり、社内基盤もすでにしっかり整っています。その方向性には私たちも強く共感しているのですが、今回はあえて一度そこを離れて、前提から組み直すとどうなるかを試してみることにしました。理由は3つあります。 1つ目は、AI に spec から開発を駆動させるには、その spec を解釈するための価値基準や判断の根拠が前提として必要だと考えたからです。これがいちばん大きな理由でした。社内基盤の上に乗ったとしても、AI が「何を基準に判断すべきか」を持たないうちは自走できません。基盤の良し悪しの問題ではなく、その手前で価値基準と過去の判断の根拠を溜める前工程が、私たちにはまだ必要だったということです。だからまずはそこを溜め込む段階を踏んでから、成熟した先で既存の仕組みに接続する、という順序を選びました。 2つ目は、既存の正解の上に乗ると、その枠内での最適化しかできないからです。この案件は新しいプロダクトの立ち上げであると同時に AI-Native 開発の PoC でもあるので、いったん既成の枠を外して前提から組み直すことに意味があります。AI-Native として本当は何が必要なのかを検証したかったので、別のアプローチで組み立て直すことで見えてくるものを優先しました。社内基盤を使うプロジェクトと、別解を試すプロジェクトが並走すれば、会社全体として AI-Native 開発の幅も広がります。 3つ目は、Claude Code 本体の進化に身軽についていきたかったからです。Claude Code には plugin や Agent Teams のような、開発の組み立て方そのものに関わる機能追加が数ヶ月単位で入っていて、AI に開発をさせる足場の作り方が次々に変わっていきます。こうした新機能をすぐ取り込めるよう、 CLAUDE.md と rules、skill、hooks だけの軽い構成で試したかったという事情もあります。標準機能だけで素朴に組むことで、何が効果を生んでいるのかの切り分けもしやすくなります。 念のため書いておくと、この独自路線は社内基盤と対立するものではありません。あくまで今の段階での選択であって、蓄積が成熟したら、社内基盤の考え方を取り込んだり、逆にこの案件で得たやり方を社内のほかの案件でも使えるようにしたり、という双方向の合流を視野に入れています。 この記事でいちばん伝えたいこと:機構は外から、判断基準は自前で 具体例に入る前に、この記事を貫く中心の考え方から書きます。 AI agent は、モデルと、その周りを取り囲む機構(ハーネス)でできていると言われています(Agent = Model + Harness)。このモデルを取り囲む環境・制約・フィードバックを設計する規律は、2026年2月に Mitchell Hashimoto 氏がブログ( 注2 )で "engineer the harness" と名付けて以降、ハーネスエンジニアリングと呼ばれて急速に広まりました。prompt engineering から context engineering、そして harness engineering へ、という発展の系譜です。 ハーネスにはさらに、モデル提供元が出荷する内側ハーネス(Agent SDK など)と、その上に私たちが組み立てる外側ハーネスがあります。Thoughtworks の Birgitta Böckeler は、外側ハーネスを agent の前に指針や制約を渡す Guide / Guardrail(feed-forward)と、agent の出力を観測してフィードバックを返す Sensor(feedback)に分けたうえで、ハーネスの議論は禁止やガードレールの側に偏りがちで、観測の側はむしろ手薄になりやすいと指摘しています。 ハーネスには大きく3つの役割があります。 正解を示す こと 結果を観測する こと 危ない操作を止める こと このどれもが、AI に安心して任せるために欠かせません。私たちはこの偏りに違和感を持ち、3つの役割を等しく支える機構がどこにあるのかを考えました。 ここで私たちが立てた仮説はこうです。この3つを支える機構は、これからプラットフォーム側が整えてくれる。評価ループ、hooks、権限のプロンプト、サンドボックスといった機構は、汎用部品として外から提供される方向に向かっている。実際、危ない操作を事前に止める hooks や権限のプロンプトは Claude Code 本体に組み込まれていて、自前で実装するものから既製品として使うものへと、徐々に移ってきています。だとすれば、その機構づくりは私たちのチームでは「できるだけやらない部分」として切り分けてよい、と考えました。 一方で、3つの役割すべてに共通して必要になるものがあります。何を正解とし、どこからを合格とし、何を危険とするか、それぞれの判断基準です。これは、この案件ならではの価値観そのものなので、外からは提供されません。観測エンジンや停止機構は買えても、その基準は各案件固有の判断の産物で、外注できない。整理すると、三本柱のすべてについて「機構は買える/判断は自前」という構図になります。 機構 判断基準 正解を示す context を供給する足場 何を正解とするか 結果を観測する 評価ループ・eval 基盤 どこからを合格とするか 危険を止める hooks・権限・サンドボックス 何を危険とするか 出どころ プラットフォームが提供(買える) 案件固有(自前で持つしかない) なぜ「判断基準」を集めるのか 個別の正解を一つひとつ集めていくより、判断基準を与えるほうが、応用が効きます。1つの正解は点にすぎませんが、判断基準はその点を生み出す規則です。だから、まだ出会っていない状況に遭遇しても、基準さえあれば AI は自ら考えることができます。 例えばデータの書き換えについて、私たちは「InsertOrUpdate(UPSERT)は使わず、Read で存在を確かめてから INSERT か UPDATE に分岐する」という方針を残しています。UPSERT は全カラムを指定しないと意図しないリセットが起きるためです。この1つの方針が、AI が新しい書き込み処理を書くときの指針にもなり、reviewer が UPSERT の紛れ込みを一次チェックする観点にもなります。Anthropic の言葉を借りれば( 注3 )、正解を示すというのは「モデルの望ましい挙動を最も生みやすいコンテキストを与えること」で、観測するときの基準もそこから派生します。同じ判断基準が、正解を示す側にも、結果を観測する側にも、両方の基盤になっているわけです。 機構と判断基準には、もうひとつ大事な違いがあります。「寿命」が違うということです。Anthropic 自身も、ハーネスの各構成要素は「いまのモデルにこれができない」という前提のうえに置かれているので、その前提は stress test して、要らなくなったら外していく対象だと述べています。( 注4 )実際、ある世代のモデルで必要だった構成要素が、次の世代では不要になったり、別の組合せに置き換わったりしていく例も出てきています。もちろん、test のようにモデルがどれだけ進化しても外せない観測の機構もあるので、すべての機構が一律に失効していくわけではありません。それでも、機構の側には入れ替わりがある一方で、目標と正しい判断基準を与える側は廃れずに残ります。だからこそ判断基準は、特定のツールの設定ファイルに埋め込むのではなく、自分たちのリポジトリに、可搬な形で持っておくべきだと考えました。 ここからは、この考え方を実際の開発でどう形にしたのか、4つの具体例と、それによる変化、それらを社内のほかの案件で共有する方法について書いていきます。どの取り組みも、機構を作り込むことよりも、その上流にある判断基準を残すことに重点を置いた話として読んでもらえると、つながりが見えてくると思います。 1. コードの一貫性:言葉の意味や正しいやり方を1つに固定する 1つ目は、コードの一貫性です。 既存のコードベースを途中から AI-Native に切り替えるために、最初にやったことは大量のリファクタリングでした。人間ならあまり気にしないようなちょっとした言葉のゆれが、AI の判断を狂わせてしまうので、意味を1つに揃えておく必要があります。同じ概念が場所によって違う名前で呼ばれていたり、書き方が場所ごとに違ったりすると、AI はそのたびに周辺から「この案件ではどう書いているか」を推測することになり、出力がぶれます。 そこで、二段階で揃えました。 まず、同じ言葉が複数の意味を指している状態や、その逆をなくして、「1つの概念には1つの名前、1つの名前には1つの意味」というところまで徹底しました。命名は AI がコードベースの全体像を掴むときに最初に頼る手がかりなので、ここがぶれていると、その先のどんな指示や設計書を渡しても精度が落ちてしまいます( 注5 )。 次に、正しいやり方が何通りもあるものについて、どれでやるかを決めてルールとして書き残しました。例えばエラーの扱い、ページネーションの実装、enum の持ち方のように、「正解は複数あるが、案件としてはこの形に揃える」というものをルール化しておく。こうしておくと、今後同じような課題にぶつかったとき、AI は解決の方法を迷わず選べます。前の章で書いた「1つの正解(点)」ではなく「判断基準(規則)」を残す、というのを地でいく作業でした。 この期間、構造を整えながら、差し引きで万単位の行数のコードを減らしました。機能を削ったのではなく、重複や不要な抽象を整理して、同じことをより少ないコードで表すようにした結果です。AI が読まされる量が減り、1つの概念にあたる場所が一箇所に集まることで、AI に渡す文脈はノイズの少ないものになります。コードベースを整えること、それ自体が、AI のための環境整備の土台になる作業だったと考えています。 2. 判断を残す仕組み:spec / design と RDR / ADR 2つ目は、判断を残す仕組みです。 私たちは、仕様書(spec)には「いまどうなっているか」だけを書いて、「なぜそう決めたのか」「どの案を捨てたのか」という判断は別のファイルに分けて残しています。判断の経緯を残しておかないと、しばらく経ってから「なぜここをこう決めたんだっけ」と人も agent も何度も悩み直すことになります。一度悩んだことを文字にしておけば、次の人や AI はその上から考え始められます。 設計(design)の判断には、もともと ADR(Architecture Decision Records)という考え方があり、社内基盤でも使われています。私たちはこれを仕様の側にも持ち込んで、なぜその仕様にしたのかという判断を RDR(Requirements Decision Records)という専用のファイルに残す方針にしました。spec 本体は「現時点の仕様」を表し、RDR は「そこに至るまでの判断の積み重ね」を表す、という役割分担です。 ADR や RDR を「どう書くか」「どんなときに新しいレコードを作るか」といった作法は、 decision-record という skill にまとめてあります。記録そのものは各案件に固有のものなので持ち運べませんが、「記録の作り方」のほうは共通の手順として配ることができます。 判断の経緯や却下した代替案を本文に混ぜると、「今の仕様はどれか」を読み取りにくくなってしまいます。現時点の要求だけを spec に残し、判断の層を RDR に切り出すことで、レビュー対象をノイズから守っています。 AI 側から見ても、この分離には意味があります。エージェントは普段は現状(spec / design)だけを読み、仕様を変えるときにだけ、紐づく RDR / ADR を必要に応じて引きにいきます。こうすると、AI に渡す context には「いまどうあるか」だけが入り、過去の議論や却下案がノイズとして混ざらない状態を保てます。 Git を origin に、Notion を読む場所に ドキュメントの原本はすべて Git に置いていて、Notion には自動で同期したコピーを置いて、読む場所やコメントをつける場所として使っています。 Notion をそのまま origin にすると、ドキュメントがチームの同意なしに誰でも編集できる状態になり、設計や実装との整合性が破綻します。逆に Git を origin にすると、Product Manager(PdM)や Designer に GitHub の Pull Request(PR)レビューを強いることになり、レビュー参加の敷居が上がってしまう。そこで、origin は Git に固定したうえで、Notion 側は「読む場所」「コメントをつける場所」と割り切りました。これで、非エンジニアのレビュアーは使い慣れた Notion でレビューに参加できますし、変更履歴は git log に残ります。 Git から Notion への同期は私たちでスクリプトを書いていますが、両者をつなぐ構成自体はありふれたもので、技術的には難しい話ではありません。むしろ難しかったのは、どちらを origin にするかという方針を最初に決めて、運用のなかでぶれずに守ることのほうでした。 レビューを skill に蓄積する そして、集まったコメントは職種ごとのレビュー用 skill に反映していきます。プロダクトの観点もデザインの観点も、一度もらった指摘によって、次からは AI による一次チェックができるようになります。 コメントを集めるところには、1つ実装上の落とし穴がありました。Notion のコメントは、解決済み(resolved)にするとコメント API からは取得できなくなります。レビューのコメントは指摘が反映されれば解決済みにしていくものなので、コメント API だけを見ていると、いちばん学びになる「対応済みの指摘」がごっそり抜け落ちてしまう。そこで私たちは、コメントを集めるときはコメント API ではなく履歴 API のほうから取得するようにしています。 例えばプロダクトのレビュー用 skill は、いつも的確な指摘をくれるメンバーのコメントから育てています。他の案件からはなかなかレビューをお願いしづらい立場の方でも、その観点を AI がいつでも一次チェックとして返してくれるようになるので、価値が大きいと感じています。レビューがその場限りで消えずに AI の中へ積み上がっていくのも、判断基準を残すことの1つの形です。 spec → design → plan → issue のパイプライン ここまで書いてきた仕様書や設計書、それにレビューの skill は、つなげて連続的に動かすこともできます。一つひとつを手で進めても十分機能しますが、つなげておくと、仕様書の変更がトリガーとなって実装まで自走させることもできます。 私たちは3つのドキュメントを使い分けています。 ドキュメント 役割 対応する skill spec 仕様書 spec-review design 設計書 design plan 手順書 plan それぞれに専用の skill があって、新しい仕様書が入ってくると、まず spec-review skill がその内容をレビューします。良さそうだと判断されたら、次に design skill で設計書をつくり、そこから plan skill で実装計画を立てます。plan skill は、もともとある plan モードを少し拡張したもので、立てた計画をファイルとしてリポジトリの中に残し、その手順の一つひとつに進捗のチェックがつくようになっています。リポジトリにファイルとして残しておくことで、session をまたいでも、別のメンバーが見ても、その計画と進捗をそのまま読める状態になります。 最後の issue skill だけは少し毛色が違っていて、ドキュメントというより、できあがった計画を実装処理へ渡すための受け渡し役です。issue skill で GitHub issue を用意し、そこに plan ファイルへの参照を入れておくと、GitHub Actions がそれを拾って実装を進め、終わると PR を出してくれます。つまり、仕様書の変更がトリガーとなって、ここまで一気通貫で進められるというわけです。 このパイプラインの土台になっているのは、それぞれの skill のなかに書き留めてきた判断基準です。spec-review が見るべき観点も、design や plan の組み立て方も、すべて私たちが少しずつ言葉にしてきたものです。パイプラインの組み立て自体は Claude Code の標準機能と GitHub Actions の組み合わせなので、目新しさはありません。新しいのは、機構の側ではなく、その上に積み上げた基準のほうです。 3. 検証ループ:推論ではなく、決定論を用意する 3つ目は、テストの話です。AI に開発を任せるとき、「ちゃんと動いていることをどう確かめるか」は、前章で挙げた三本柱のうち観測の役割にあたります。 ブラウザを動かして操作をひと通り確かめる、通しの End-to-End(E2E)テストを思い浮かべてください。AI にこれをやらせる方法として、Claude のように、その場でブラウザを直接操作してもらうやり方があります。ただし、この方法は毎回 AI の推論が入るので、結果がゆれますし、実行のたびにトークンも消費します。テストの本来の目的は「同じ入力なら同じ結果になること」を確かめることなのに、確かめる側がゆれてしまうと、本末転倒です。 そこで私たちは、AI を活用するのはテストの定義を作るところまでにして、実行のほうは推論を挟まない確定的なやり方にしました。AI に新しい機能の操作手順と期待される結果を書いてもらい、書き終わったら、その後の実行ループからは AI を抜く、という分け方です。こうすると、ゆれる可能性のある推論から決定論に変わって、同じ入力なら必ず同じ結果になります。Continuous Integration(CI)でも手元でも同じスクリプトが同じように走るので、差分が出れば、それは AI のゆらぎではなく、プロダクトに本当の変化があったサインだと、はっきり切り分けられます。 実際に、メルカリ NFT の主要なフローを Playwright で組みました。出品、購入、オファー、承諾、買取、配送といった一連の流れを、売り手と買い手を切り替えながら通しで実行します。これを「regression test を実施して」の一言で AI が全シナリオを走らせる状態にしてあるので、コマンドを覚える必要はありません。 確かめ方そのものを「正解」として固定するというのも、判断基準を残すことの1つの形です。AI に任せる範囲を広げるほど、人の代わりに結果を観測する機構は大事になります。そのときに、観測する側まで AI に任せきってしまうと、ゆらぐ判定でゆらぐ実装を確かめるという不安定な構造になってしまいます。確定的に動く部品を観測の側に据えることで、AI に任せる範囲を安心して広げることができるようになりました。 4. 個人情報(PII)の多層防御 4つ目は、セキュリティ、とくに個人情報の扱いです。社内でも注目されているテーマです。 commit や PR にうっかり個人情報が混入することは、人でも AI でも起こりえます。私たちは commit も PR も、それに Jira チケットの作成も AI にやってもらっているので、AI に開発を広く任せるのであれば、個人情報の保護にも AI を活用するのは自然な流れだと考えました。 そこで、一連の流れに対して個人情報を保護する機構を、三段の防御として載せることにしました。 三段にした意図は、どこか一段が落ちても別の段がカバーする構成にしたかったからです。一段目をすり抜けても二段目で止まり、それも越えてしまったら三段目で後始末できる。完全にミスをなくすことを期待するのではなく、ミスは起こる前提で複数の網を重ねる、というスタンスです。 もうひとつ、skill によるコメントなどで個人情報を引用するときは、先頭の数文字だけ残してあとはマスキングするルールにしています。AI のコメントは、PR の本文に残ったり、コミットメッセージや別ドキュメントに引用されたりと、思わぬ場所に流れていきます。該当箇所が特定できる程度に頭を残してそれ以外を伏せておけば、AI があとでどこかに書き残すときにも、本物の個人情報が混入することはありません。 この例では、機構(hooks や CI のスキャン、復旧手順)も判断基準(何を個人情報とみなすか)も、どちらもプラットフォームでなく私たち側で用意したものです。それでも、機構と判断基準を別物として扱い、判断基準を機構の上流に置く、という構図は変わりません。両者を区別して別々に扱う、という構図自体は崩れないということを、この例はあらためて思い出させてくれました。 どれくらい変わったか ここまでに紹介した4つの取り組みは、実際にチームの開発にどんな変化をもたらしたのか。指標としては定量的に見られるものと、日々の運用感覚として感じる定性的なものの両面で、いくつかわかりやすい変化が出てきました。 コードの一貫性を整え、開発の効率を上げる skill を入れていった結果、チーム全体の PR の数そのものが増えました。しかも、この案件は1月末にいったん開発チームを解散していて、それ以降はエンジニアが2人、プロダクトとデザインが1人ずつ、という少人数の体制です。チーム全体の流量が増えているうえに人数が少ないので、1人あたりで見れば何倍にもなっている計算です。 これは AI を導入したから自動的に増えた、というよりは、AI が力を出せるようにコードや判断基準を先に整えてきた結果だと考えています。漫然と AI を導入するだけでは、同じ結果にはならなかったはずです。 もうひとつ、PR の中身についても触れておきます。開発全体の PR 流量が増えている一方で、修正系の PR の割合は抑えられています。新機能や改善の PR の伸びが、修正系の伸びを上回っている、ということです。これは PR の乱造ではなく、品質を上げた上で流量も増えているという、非常に大きな成果だと思っています。 それとは別に、アラートの調査を AI に任せるための skill(監視基盤を直接触れるようにしたもの)も整備しました。アラートの URL を渡すだけで原因を調べて修正までまとめてやってくれるようになりました。日々の運用感覚としても大きい変化でした。 共通と固有を分ける:ほかの案件に持っていくために ここまで積み上げてきた仕組みは、この案件だけで閉じてしまうのはもったいないので、社内のほかの案件でも使えるようにしておきたいと考えました。そのためにやったのは、どこが共通で、どこがこの案件固有のものかを、はっきり分けておくことです。 この「共通と固有を分ける」やり方は、たった1つの小さな判断から始まりました。社内共通のフレームワークである monorail をアップグレードしたときのことです。monorail のチームは、アップグレードの共通手順を monorail-upgrade という skill で提供してくれています。ただ、私たちの案件には、それだけでは足りない固有の手順がありました。そこで、その名前のうしろに -local をつけた monorail-upgrade-local という skill を作って、固有の手順をそちらに置きました。この小さな切り分けが、いまの形の原型になりました。 私たちのプロダクトは、Backend と Frontend が別々のリポジトリに分かれていますが、共通で使える skill は、1つの plugin にまとめて配っています。そして、それぞれのリポジトリに固有の部分は、その plugin を上書きする -local な skill に入れています。こうしておくと、片方のリポジトリで skill を改善するともう片方にも横展開しやすくなりますし、skill の名前を見ただけで「どこからが共通で、どこからが案件固有か」が判別できます。 このやり方は、Claude Code の CLAUDE.md と CLAUDE.local.md の考え方を応用したものです。plugin を案件ごとに上書きする仕組みは、Claude Code に機能として用意されているわけではなく、Feature Request として GitHub に issue が出ている段階です。そこで私たちは、 -local という名前のつけ方を決めて、その規約だけで共通と固有の境界を表すことにしました。人によってはすでに自然にやっていることかもしれませんが、これを暗黙のままにせず、命名ルールとして明示しています。 大きな仕組みを最初から設計する必要はありませんでした。小さな判断を、次の人がそのまま使える形で残していけば、それが積み重なって、いつでも広げられる土台になります。 補:個人的な思想 ここからの話は、これまでの章とは少し性質が違います。この案件の特性から導かれた話というよりは、私個人の運用観の話だと思って読んでもらえると、はずれが少ないと思います。同じことをやろうとしている人が、これと違うやり方を選んでもなんら問題ありません。今回の記事に紛れ込ませているのは、この4ヶ月のなかで自分の中にまとまってきた考え方を、せっかくなので書き残しておきたかったからです。 専門知識は skill に集め、agent は薄いラッパーに保つ Claude Code には、知識や手順をまとめておく skill と、独立した context を持って動かせる subagent という2つの仕組みがあります。コミュニティの best practice を眺めていると、機能領域ごとに専用の subagent を作って、そこに専門知識を持たせていくやり方をよく見かけます。私が選んでいるのはこれとは違うルートで、専門知識は skill にまとめて、agent はそれを呼ぶための薄いラッパーに留める という分け方です。 agent と skill は対立する選択肢ではないと思っています。agent を作ってもよいのですが、agent には「この場面ではこの skill を、次にこの skill を呼ぶ」というルーティング情報だけ書く、と考えています。判断基準や手順、ドメイン知識といった本体は、すべて skill 側に書く。こうしておくと、同じ判断基準が複数の agent に重複して埋め込まれることがなくなりますし、skill を更新すれば、それを呼ぶすべての経路に反映されます。 例えば Anthropic は、長時間動く agent harness の設計について、実装する generator agent とテストする evaluator agent を別の agent として分離することを推奨(注4)しています。生成する agent に自分の仕事を批判させるよりも、別の agent を評価役として厳しくチューンするほうがやりやすい、という理屈です。agent を切るとしたら、こういうメイン context からの隔離と独立した判断、それに伴う権限スコープが単独で必要なときが良さそうです。その場合も専門知識を埋め込まずに、必要な skill 群を呼ぶ薄いラッパーとして組みます。agent の固有価値は隔離・独立した判断・権限スコープであって専門知識そのものではない、というのが私の見立てです。専門知識は skill 側に集めたほうが、再利用しやすく、知識の重複も起こりません。 rewind ではなく、新しい session を fork する Claude Code には、過去のやり取りを巻き戻して途中の状態から再開できる rewind という機能があります。便利な機能なのですが、私は意識的にあまり使わないようにしています。理由は単純で、rewind は context の rebase に近いところがあって、やってきた流れの一部が history から消える形になるからです。 これは好みの問題で、git の rebase が悪いと言いたいわけではありません。git log がきれいになるメリットと、流れがそのまま残るメリットには、どちらにも価値があると思います。ただ私自身は流れを残しておきたいほうで、後から「あのとき何を試して、何で失敗したか」を辿れるほうに価値を感じます。 やり直したいときは、rewind の代わりに新しい session を fork するようにしています。レビューしたいときも、進捗の区切りごとに分けたいときも、その都度別 session を立てます。session を分けたほうが、それぞれの context を小さく保てるので、結果的に動きも安定すると感じています。 plan ファイルと TODO.local.md が session 間のバトンになる session を多めに切る運用には、1つ前提があります。進捗を別 session に渡せる形で残しておく必要があるということです。これを担っているのが plan ファイルと、それからもうひとつ、 TODO.local.md というファイルです。 plan ファイルのほうは、前章で書いた spec → design → plan → issue のパイプラインで出てくる、あの plan です。手順の一つひとつに進捗のチェックがついていて、ファイルとしてリポジトリに残っているので、別の session を立てても、最後にどこまで終わっていたかをそのファイル 1 枚で引き継げます( 注6 )。同じことが git worktree を切って並列で動かしているときにも当てはまります。worktree A で進めていた作業を worktree B から続けることもできますし、agent 間でタスクを引き渡すときも、plan ファイルの該当箇所を指せば、お互いのなかで同じ進捗の理解が再現できます。 TODO.local.md のほうは、個人レベルの todo を扱うためのファイルです。チケット化するほどではない、あるいはチケット化する前段階のもの、例えば「あとで気が向いたら直したい」「明日の朝いちで手を付ける」といったレベルの個人 todo を、ここに置いています。これを管理するための todo skill も用意していて、追加、完了マーク、一覧表示、コード内 TODO の集約( 注7 )などができます。チケットほど重くなく、その場の独り言で消えてしまわない、中間地点のタスク管理に意味があります。 plan ファイルと TODO.local.md の2つで、チケットに上げるほどではない作業の進捗が、session や worktree、agent をまたいで運べるようになっています。これが、session を多めに切る私の運用を支える土台です。 中心の軸とのつながり ここまで書いたのは私の運用観なので、章の頭で断ったとおり、違うやり方を選んでもなんら問題ありません。ただ、最初の章で書いた「機構は外から、判断基準は自前で」という軸を頭に置きながら振り返ると、1つ気付くことがあります。 skill と plan ファイルと TODO.local.md は、判断基準が宿る側にあるということです。判断基準や手順、進捗の文脈を、可搬なファイルとして残しているからです。一方の agent や rewind、session の制御は機構の側で、判断基準そのものを抱え込ませる対象ではない、と私は考えています。 機構と判断基準を分けて、判断基準を機構の上流に置く、という構図を貫こうとすると、自然にこういう運用に寄っていくのではないかと思います。 まとめ AI を動かす機構はこれからどんどん外から手に入るようになるので、私たちの仕組み化は、その上に載せる判断基準を残すことに集中してきました。 何を正解とし、どこからを合格とし、何を危険とするか コードの一貫性も、判断の記録も、テストの固定も、個人情報の保護も、すべて「判断基準を、価値観と理由ごと残していく」という同じ考え方でつながっている 観測や停止の機構はモデルが進化するにつれて入れ替わったり、外せたりする部分がある。一方、判断基準は廃れにくい資産なので、特定のツールの設定ファイルに埋めるのではなく、自分たちのリポジトリに可搬な形で持っておく これらが、この4ヶ月の試行錯誤から得た学びでした。 結果が出るに越したことはないのですが、まだ誰も正解が分かっていない領域なので、いまは結果そのものよりも、改善していける過程に重きを置く段階だと思っています。同じようなことに取り組んでいる方、真似してみたい方がいれば、ぜひお話ししたいです。最後まで読んでいただき、ありがとうございました。 脚注 注1 ここでいう「判断基準」とは、ドメイン知識と開発ルールを併せたものに近いと、いまは捉えています。ドメイン知識のほうには、その業務を成り立たせるための知識だけでなく、そのドメインで何に重きを置くかという価値観も含まれます。 注2 Mitchell Hashimoto, "My AI Adoption Journey"(2026-02) https://mitchellh.com/writing/my-ai-adoption-journey 注3 Anthropic, "Effective context engineering for AI agents"(2025-09) https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents 注4 Anthropic, "Harness design for long-running application development"(2026-03, Prithvi Rajasekaran) https://www.anthropic.com/engineering/harness-design-long-running-apps 注5 これは人間も間違える原因になるので、AI を使わないとしても徹底したほうが良いと思っています。今回は AI-Native の話なので、本筋ではありませんが。 注6 Justin Young, "Effective harnesses for long-running agents"(2025-11) https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents session をまたぐ継続性を artifact(この場合はテキストファイル)で確保するという趣旨が該当します。 注7 これは案件内で共通のものなので TODO.md にまとめられています。IDE を常に開いていた頃なら、こういった TODO は自動的に収集してきてくれましたが、最近は Claude Code だけで済んでしまうので、こういったものが必要になりました。
こんにちは。メルペイ Growth Platform チームでエンジニアリングマネージャーをしている @yo-gawa です。この記事は Merpay & Mercoin Tech Openness Month 2026 の 19 日目の記事です。 私は 2018 年にメルペイに入社し、メルペイクーポンや決済に応じたポイント還元の仕組みなど、メルペイのキャンペーンを支える基盤の開発・運用に携わってきました。 【書き起こし】メルペイのキャンペーンの舞台裏 〜Growthを支える仕組み〜 – 小川 芳樹【Merpay Tech Fest 2021】 | メルカリエンジニアリング 現在は、メルカリグループ全体のグロース基盤のエンジニアリングを統括しています。 本記事では、私の視点からメルカリグループのグロース基盤の歴史と、どのようにチームが拡大してきたのかをお伝えします。 Growth Platform の立ち上げ メルカリグループでは、ポイントやクーポンを用いたマーケティング/キャンペーンが非常に活発に行われています。既存のお客さま向けの施策に加え、メルカリハロやメルカリモバイルといった新規事業のグロースにも活用されています。また、お客さまとのコミュニケーションチャネルとして、アプリ内バナー、メール、Push 通知、「あなたへのお知らせ」等も併せて使われています。Growth Platform チームは、こうしたグロース活動を支える基盤・仕組みづくりを担うチームとして機能しています。 「Growth Platform」というチーム名称が生まれたのは、ちょうど 5 年前の 2021 年 7 月でした。 「Growth UXチーム」が「Growth Platformチーム」に名称変更! 組織改編で見えたPMの役割やチームの変化 | mercan (メルカン) 上記の記事にもある通り、「共通する機能・基盤を横断的に見て、システム開発や運用の観点から下支えする」というミッションのもと、さまざまな取り組みを進めてきました。 Growth Platform というチームが生まれる前、2019 年のメルペイサービス開始直後は、より多くのお客さまにメルペイを使っていただくため、決済に絡めたクーポンやポイント還元キャンペーン施策を次々と打ち出していました。そのスピード感を支えるべく、独自にキャンペーン基盤を開発してきました。 一方で、マーケットプレイス事業にも同様にキャンペーンや Customer Relationship Management(CRM)を実行する基盤が存在していましたが、長いメルカリの歴史の中で生み出されてきた PHP 製ツールには多くの負債があり、メンテナンス性の観点からも、オペレーション事故が起きやすい構造になっていました。メルペイ側でも、それらのツールを利用することにリスクを感じていました。 そこで、メルカリとメルペイのエンジニア間で実務的なコラボレーションをスタートし、古いツールを廃止して、新たに Engagement Platform (EGP) という内製マーケティングエンジンを共同開発する方向性で合意しました。 その後、2023 年 1 月よりメルペイ内に統合組織を作り、「JR (Japan Region) Growth Platform」として、一丸となった開発体制をスタートさせました。詳細は当時のエンジニアリング統括であった keigoand の記事にまとまっています。 Merging Teams for a Growth Platform | Mercari Engineering 記事にもありますが、当初はそれぞれのチームで開発の進め方が大きく異なりました。メルペイが比較的小規模で立ち上げを進めた背景もあり、独自のオペレーションツールやミドルウェアがあり、エンジニアリングカルチャーも異なっていました。また、メルペイは金融事業を担っていることもあり、システム品質の担保も重要です。リリースプロセスの厳格化や、QAなどの各種基準への準拠などについて認識を合わせながら開発を進めることは苦労も伴いましたが、チームのケイパビリティは格段に向上しました。 グループの拡大とともに 先述した通り、Growth Platform チームはメルペイに所属しながらも、メルカリグループ全体を支えるチームです。 この数年、メルカリでは複数の新規事業が立ち上がりました。 メルカリShops メルコイン メルカリNFT メルカリハロ メルカリモバイル 越境ビジネス メルカリグローバルアプリ etc これらのビジネスやプロダクトの Product-Market Fit を支えるべく、Growth Platform ではさまざまな開発を行ってきました。例えば、新しいサービス画面上でのキャンペーンバナー表示や訴求、新規のお客さまへのクーポン/ポイント進呈、利用実績に応じたロイヤリティプログラムなどは、一見共通でありつつも、それぞれのサービスのデータを基盤に統合し、マーケティングチームが柔軟かつ高速にキャンペーンを打ち出せる仕組みが必要です。また、グローバル展開においては、各地域の言語や、クーポンにおける通貨の違いへの対応などもスコープになります。 さらに(一部例外はありますが)、メルカリのサービスは 1 つのメルカリアプリ上で動いています。起動直後に表示される「ホーム画面」や、マーケットプレイスにおける「商品詳細画面」「取引画面」でのキャンペーン訴求は、お客さまにサービス認知を獲得するうえで非常に重要です。そのような訴求エリアが限られる中で、どのサービスがそれぞれのお客さまにとって有用なキャンペーンであるのか、コミュニケーションチャネルがノイジーになりすぎないのか、といった最適化にも Machine Learning チームが取り組んでいます。 組織の拡大 事業の拡大に対応するため、Growth Platform 組織も拡大を続けてきました。 2022 年 6 月にインド・ベンガルールに開発拠点として Mercari India を設立して以降、設立当初から一緒に開発を進めています。当初はクーポンドメインを共同で開発するところからスタートしました。新たなビジネスに対応したクーポン機能の開発を進めながら、mercari-api という PHP 製モノリスからマイクロサービスへのマイグレーションという大規模プロジェクトも自律的に推進しています。 Migrating Coupons from Monolith to Microservice – Mercari India 近年ではクーポンドメインだけでなく CRM ドメインでも開発体制を拡大し、Backend、Frontend、Mobileのエンジニアが一体となってインド拠点のみで機能開発を完結するような事例も出てきています。 現在は日本とインドのエンジニアが約2:1の割合で協働しています。また、日本とインドのエンジニアリングマネージャー同士、密に連携しながら開発を推進しています。 Growth Platform の全体ミーティングでは、知見共有を含めた交流をエンジニア同士で積極的に行っています。加えて、定期的にインドオフィスへ出張したり、インドのエンジニアが東京オフィスを訪問するタイミングに合わせて交流を深めたりしています。 (インドオフィスに設置されているオブジェへのサイン) 一方で、使用言語の壁は依然として課題です。メルペイは従来、日本語中心でコミュニケーションしてきましたが、元マーケットプレイスのメンバーやインドオフィスのメンバーは英語中心です。Growth Platform チームの全体ミーティングは英語で行う一方、メルペイ全体のミーティングは日本語で実施しているといった状況下で、GOT(通訳)チームのサポートも借りながら、日本語話者・英語話者の双方が歩み寄ってコミュニケーションを進めています。 次なるチャレンジへ Growth Platform チームの成熟は、メルカリグループのグロースとともにありました。 EGP は、さまざまな機能開発を経て大きな進化を遂げています。 EGP – Mercari’s CRM Platform: Built Once, Powering Many | mercari GEARS 2025 AI と作る HTML ベースの LP エディタ EGP Code を内製した理由 | メルカリエンジニアリング また、メルペイに特化した基盤である Santa サービスも進化を続けています。 メルペイのキャンペーン基盤をルールベース汎用システムに書き直し、Otoku Revolutionするまでの話 | メルカリエンジニアリング 基盤開発フェーズは一巡したと捉えていますが、ビジネスを支える基盤としてはまだ道半ばにあります。特に、ビジネスの要求に対するアジリティとシステムの信頼性向上は継続的な課題です。 そこで今後は、グループそれぞれのビジネスグロースをより直接的に支えつつ、守りも固められる開発体制をつくるために、Growth Platform 体制としては一区切りとし、組織を CRMチーム と Incentiveチーム に分け、それぞれのミッションを担っていきます。 CRM チームは、マーケットプレイスをはじめとする各事業の成長を後押しするため、プロダクトと密に連携しながら、EGP を活用したお客さま向けコミュニケーション機能の開発・改善を進めていきます。 Incentive チームは、メルペイ Payment & Customer Platform の一員として、インセンティブを汎用的かつ安全に取り扱える仕組みづくりを推進していきます。 チームは分かれますが、メルカリグループ全体で活用される共通 Foundation として、連携は継続していきます。 まとめ ここまで、Growth Platform のプロダクトやチームの拡大について簡単にご紹介しました。プロダクトや技術の詳細は、本エンジニアリングブログやカンファレンスでメンバーが紹介していますので、そちらもぜひご覧ください。 これからもメルカリグループの可能性を広げるための仕組みづくりを追求していきます。 次の記事は abcdefujiさん と becosukeさんです。引き続きお楽しみください。
こんにちは。メルペイの Balance Team で Tech Lead をしている @kobaryo です。この記事は「 Merpay & Mercoin Tech Openness Month 2026 」の18日目の記事です。 はじめに Balance Team では お客さまの残高やポイントを扱うマイクロサービスをはじめ、債権を取り扱う Debt Service も管理しています。先日公開された @imamu さんの「 事業者請求払いのための与信管理マイクロサービスの設計 」で、与信を扱う Credit Service を中心に事業者請求払いの設計が紹介されましたが、本記事ではその中で取り上げられた「債権を任意の軸で集計する仕組み」を Debt Service 上でどのように表現したのかについて紹介します。 事業者請求払いでは、与信管理のために「与信枠ごとの未返済債権」を高速に取り出したい、また請求のために「請求先ごとの債権の一覧」を把握したい、という2つのニーズが生まれます。お客さまや事業者ごとの残債を管理する既存のテーブル DebtAccount はお客さまや事業者を表す ID という単一の軸に縛られており、この2つの独立した軸を同時に管理できませんでした。この壁を越えるために、「DebtAccount は債権の操作ログ DebtLog を集計したビューである」という発想から DebtAccountView を設計しました。1つの DebtLog を異なる軸で集計された複数のビューに畳み込むことで、与信枠軸と請求先軸を独立して管理でき、将来の新しい集計軸も最小限の変更で追加できます。 Debt Service が管理する「債権」とはなにか 事業者請求払いでは、我々が取引のたびに代金を立て替え、後からまとめて事業者に請求します。この立替金の記録が 債権(Debt) です。Debt Service はメルペイの中で債権を一元管理するマイクロサービスで、事業者向けのみでなくお客さま向けのメルペイのクレジットなど、複数のサービスから呼ばれる下位レイヤーとして機能します。 各 Debt は「誰の(CustomerId)」「何の種別の(DebtType)」「いくらの(Amount)」立て替えかを保持します。DebtType は決済の種別や精算方法を区別するための分類で、お客さま向けの月次清算を表すものや事業者向けの請求書払いを表すものなど複数の種別が存在します。 個別の Debt が増えていくと、「ある事業者の現時点での残債はいくらか」を素早く知りたい場面が増えます。Debt を1件ずつスキャンして合算するアプローチは、Debt の件数に比例して計算コストが大きくなるため、取引のたびに現在の残債が与信上限を超えないかをリアルタイムで確認するには向きません。そのために DebtAccount というテーブルでお客さまや事業者ごと、かつ種別ごとに残債を保持しています。Debt の作成や返済が発生するたびに DebtLog (債権の操作ログ)が記録され、それに応じて DebtAccount の Amount が更新される仕組みです。 「1口座1軸」という壁 DebtAccount の設計はシンプルです。Unique key (CustomerId, DebtType) によって、「この事業者の、この種別の残債はいくらか」という問いに即座に答えられます。お客さま向けのメルペイのクレジットなど既存のユースケースではこれで十分対応できました。 請求書払いプロジェクトでは、この設計に2つの新しい要件が加わりました。1つは与信管理の要件です。詳細な説明は上述の @imamu さんの記事 に委ねるのですが、今回店舗や部門ごとなど1事業者が複数の与信枠を持てる設計を目指しています。そのため、与信枠を扱う Credit Service は決済フローの中で「ある与信枠に紐づく残債」をリアルタイムに取得できなければなりません。もう1つは請求の要件です。Invoice Service は請求先の軸でまとめた債権から請求書を発行するため、「ある請求先に紐づく債権の一覧」を取得できなければなりません。与信の軸(与信枠)と請求の軸(請求先)は完全に独立しており、1つの債権が両方に同時に紐づきます。またこれらの軸はどちらも CustomerId すなわち既存の事業者IDと独立しているため、既存の DebtAccount を用いて集計することはできません。 2つの要件はそれぞれ性質が異なります。与信計算は決済フローの中でリアルタイムで行われ、応答時間が Debt の件数に依存してはならないため、与信枠の軸でも既存の DebtAccount と同じ「1行参照で残債がわかる」事前集計が必要です。請求の要件は残債の集計ではなく、明細の作成や債権の返済のためにどの債権がどの請求先に属するかという紐づけを保持しておくことです。ただし後々事業者向けに請求額を確認できるダッシュボードを提供したいなど、請求先軸でも事前集計する必要が出てくるかもしれません。またこれらに追加して3軸目がこの先登場しないとも限りません。このように考えた場合、我々に必要なものは多軸で残債を集計でき、かつその軸と債権を紐付けておく汎用的な仕組みでした。 1つの操作を複数の軸で同時に集計する DebtAccountView この仕組みを実現するヒントは、DebtAccount の性質そのものにありました。DebtAccount は「口座」という名前のとおり実体を持つ台帳のように見えます。しかし、すべての事実は DebtLog に記録されており、DebtAccount はその DebtLog を (CustomerId, DebtType) 軸で集計し、現時点の残債を1行に集約したビューです。この捉え方に立つと、「同じ DebtLog を別の軸で畳み込んだビューが複数あってもよい」という発想が生まれます。これが DebtAccountView の設計の直感的な出発点です。 口座を分割するのではなく、集計の軸を並列に増やすというのが、設計の方向です。DebtLog は従来 DebtAccount という1つの集計ビューだけを更新していました。ここで、1つの DebtLog が既存の DebtAccount に加えて任意の数のビューを同時に更新できるようにしました。 各 DebtAccountView は既存の DebtAccount と同じ物理構造を持つ集計テーブルです。DebtAccount の設計思想をそのまま踏襲しているため、DebtAccountView 専用の新しい運用パターンを考える必要はありません。従来「1 DebtLog が1 DebtAccount を更新する」ところを「1 DebtLog が1 DebtAccount + N 個の DebtAccountView を更新する」に拡張した形です。 3つのテーブルが作る台帳の構造 この仕組みを支えるのが3つのテーブルです。 DebtAccountViews (集計した残債)、 DebtAccountViewItems (Debt と DebtAccountView の所属関係)、 DebtAccountViewLogs (Debt Account View の変動履歴)がそれぞれの役割を担います。この機能に関わる主要な3テーブルのスキーマを示します。 -- 集計した現在の残債 CREATE TABLE DebtAccountViews ( DebtAccountViewId STRING(100) NOT NULL, ViewKeyType INT64 NOT NULL, -- 1=請求先 ID, 2=与信付与先 ID, ... ViewKeyId STRING(MAX) NOT NULL, -- 指定した ViewKeyType の中の ID CustomerId INT64 NOT NULL, DebtType INT64 NOT NULL, Amount INT64 NOT NULL, ) PRIMARY KEY(DebtAccountViewId); CREATE UNIQUE INDEX DebtAccountViewsByViewKey ON DebtAccountViews(ViewKeyType, ViewKeyId, DebtType, CustomerId); -- どの Debt がどの DebtAccountView に属するか(請求書作成時に使用) CREATE TABLE DebtAccountViewItems ( DebtAccountViewId STRING(100) NOT NULL, DebtId STRING(100) NOT NULL, ) PRIMARY KEY(DebtAccountViewId, DebtId); -- 残高変動の履歴 CREATE TABLE DebtAccountViewLogs ( DebtAccountViewLogId STRING(100) NOT NULL, DebtAccountViewId STRING(100) NOT NULL, DebtAccountLogId STRING(100) NOT NULL, -- DebtLog に対応する DebtAccountLog(DebtAccount の変動ログ)を DebtAccountView に紐付けることで、間接的に DebtLog と紐付けている ) PRIMARY KEY(DebtAccountViewLogId); 上流マイクロサービスは、債権を作成する際に ViewKeyType (集計の軸)と ViewKeyId (ある集計軸における集計ID)を指定すれば、あとはその債権が返済・取消など操作されるたびに自動的に DebtAccountView が更新されます。また軸の種類を増やす際は単に ViewKeyType の種類を増やすだけで十分です。 今後の展望 現状の実装では、一度の債権の操作で更新する DebtAccountView が多くなる、すなわち集計軸が増えていくと、債権操作のレイテンシが上昇してしまいます。そこで考えられる拡張が ViewKeyType ごとに更新の同期・非同期を切り替えられる設計です。初期実装では全 ViewKeyType をデータ書き込みと同じトランザクションで同期的に更新していますが、将来同期的に反映しなければならない与信枠などの軸は同期的に、債権の変動と集計する間にタイムラグがある請求先の軸は非同期で反映するという拡張を予定しています。 また今回事業者請求払いのために DebtAccountView を設計しましたが、toB toC 問わず与信を分割したり与信を付ける先と請求する先が異なる任意のビジネスに適用できます。また、設計の本質は変動ログをピックアップし集計する汎用的なものであるため、債権に限らず残高やポイントを管理する Balance Service に導入して利用することも考えられます。 おわりに DebtAccountView は、「1つの債権に複数の集計軸を持たせたい」という要件から生まれた機能です。既存の DebtAccount 設計をベースに、「1 DebtLog → N View を更新する」という拡張を汎用的かつシンプルに実現することにこだわりました。また将来のユースケースや拡張も考え抜けたことは、実装を通じて得た達成感の一つです。 事業者請求払いはメルペイの Payment Platform としてまだ進化の途中にあります。与信管理、債権管理、請求、精算という一気通貫のプラットフォームを、各サービスの責務を保ちながら積み上げていくことは、技術的にもドメイン的にも自信を持って面白いと言えます。もしこうした課題に興味を持っていただけたら、ぜひ Balance Team やメルペイの採用・インターン情報も覗いてみてください。この記事が、Fintech や決済基盤のドメイン設計に興味を持つエンジニアにとって何らかのヒントになれば幸いです。 次の記事は yogawaさん と haoyuさん です。引き続きお楽しみください。 Appendix: 採用しなかったその他の選択肢 既存の事業者IDのみでなく与信枠軸や請求先軸など任意の単位での集計を実現する手段として、DebtAccountView 以外にもいくつかの案を検討しました。 1つ目は与信枠ごとに DebtAccount を分割し、請求先の情報は Debt に持たせる方法です。現状のスキーマの延長線上で実装することができとてもシンプルですが、請求先情報という Debt ドメインに関わりが深くない情報を列に直接追加するのはあまりに汎用的でありません。 2つ目は DebtAccount を親子関係で分割する案です。事業者全体の DebtAccount を親として、その子は与信枠の軸で分割、さらにその子は請求先の軸で分割するといったものです。DebtAccount を分割するための軸という概念を追加しそこに与信枠軸や請求先軸を入れるので、1つ目の案よりは汎用的です。しかし与信枠の軸と請求先の軸は互いに独立しているので、与信枠の軸での集計は効率よく行えるものの、請求先の軸での集計は依然効率よく行えません。加えて、与信枠軸や請求先軸のみでなく効率良く集計する必要のある3軸目が登場した場合に、1つ目の案とこの2つ目の案は対応することができません。 3つ目は Debt Service 内でなく、上流サービス側で集計を持つ方法です。Credit Service が与信枠ごとの残債を管理し、Invoice Service が請求先ごとの集計を管理する設計です。しかし Debt Service を使うサービスが増えるたびに、同じ集計ロジックを各サービスが個別に実装することになります。Debt Service 内で集計済みの情報を管理すれば、債権の元データを唯一の情報源として、各サービスへの変更通知なしに信頼性の高い集計を一元的に提供できます。この仕組みを Debt Service に置くことは、将来債権を取り扱う新しいビジネスが高パフォーマンスな集計を必要としたとき、プラットフォームとして即座に応えられる基盤への投資でもあります。
はじめに こんにちは。メルペイの @yutaro です。 「 Merpay & Mercoin Tech Openness Month 2026 」17日目として、メルペイの決済をもっと楽しく、繰り返し使いたくなる体験にするための取り組み(Otoku Revolution)について紹介します。 私は普段、Payment Platform の Balance Team で、メルペイ残高やポイントの状態管理、債権管理、会計連携、法定帳簿(法律上必要な帳簿)等の開発・運用をおこなっています。 この記事でシェアしたいのは、新鮮な体験を作るときほど、早い段階で「決済では何が起きるか」「返金や失効ではどう扱うか」「お金の流れはどうなるか」のような具体的な部分のイメージも煮詰めておくと、実装も追加施策も速く進められる、という実例です。 Otoku Revolution とは Otoku Revolution は、一言でいうと「決済するほど次の決済がおトクになる」体験を、メルペイの既存の決済基盤の上で実現するプロジェクトです。 実際の施策は、コード決済するたびにスタンプが貯まり、3回決済すると値引き特典を受け取り、次回決済時に自動で値引きされる新しい還元プログラムになりました。 (実際の決済後の画面) 決済する、スタンプがたまる、値引き特典を受け取る、受け取った特典が次回の決済で自動的に使われる。というような流れで、クーポンを選んだり、後日ポイントを受け取ったりするのではなく、次の決済で自然におトクになる体験を目指しました。 2026年5月中旬から、メルペイをご利用のお客さまの約半数を対象に試験的な導入を進めています。 決済基盤の上でこの体験(即時値引き)を作るには、キャンペーン条件、値引きを実現するリワード、決済処理、履歴、会計上の扱いを矛盾なくつなぐ必要があります。 構想初期には、周辺リポジトリをすべてcloneし、Claude Codeに読ませながら、関係しそうなサービスとデータの流れを図に起こしていました。 次のスクリーンショットは、そのときの初期アーキテクチャ図です。細部はその後整理していますが、議論のたたき台としては十分に現実に近く、どのサービスで何を扱うべきかを早い段階でそろえる助けになりました。 ここからは、この還元プログラムを実現するために値引きポイント DISCOUNT_POINT をなぜ選んだのかを紹介します。 なぜ DISCOUNT_POINT を作ったのか 最初の大きな判断は、この特典をどのような形で実現するかでした。 検討した選択肢は、大きく3つありました。 ポイントバック: 既存のポイント付与の仕組みで実現しやすい。一方、即時ではなく後日ポイントを受け取る体験になる クーポン: 特典として理解しやすい。一方、お客さまが選んで使うものになりやすく、既存の仕組みで即時反映を実現しにくかった 値引き専用の残高(DISCOUNT POINT): 完全に新しい即時値引き専用の残高を作成し、対象の決済で最優先で使われるようにする 3つ比較した結果、即時値引きという体験を実現するためには、値引き専用の残高を作成する必要があるという判断になりました。 これは通常のポイントとは別の、値引きにだけ使えるお客さま向けの残高です。 内部的には残高のように扱えますが、お客さま向けにはすぐに次の決済に使えるクーポンのように振る舞います。次回の決済の値引きを実現したいので、決済金額が値引き額より小さい場合は、使い切れなかった分を失効させます。 DISCOUNT_POINT をスピーディーに実装するために役立ったのが、約1年半前から稼働している新しい残高管理システムである Balance V2 です。 Balance V2 では、残高の種類を後から柔軟に追加できる設計になっており、種類ごとに使われ方や会計連携を宣言的に定義できます。また、残高の付与や消費、失効に伴う変動があった場合に自動で会計連携されるようになっています。 このプロジェクトではその汎用性を活かし、施策専用のサービスを立ち上げるのではなく、 DISCOUNT_POINT という新しい残高の一種として即時値引き特典を扱う設計にしました。 結果的に、全く新しい種類のクーポンであるはずの即時値引き特典を、決済基盤上では残高の延長線上で扱い実装できるようになりました。 DISCOUNT_POINT の全体像 DISCOUNT_POINT は、決済完了後にスタンプが3回溜まっていれば付与されます。付与後、次の決済前に利用可能な残高として参照され、決済手段に最優先で使われる残高として組み込まれ、通常の残高やポイント・メルペイのクレジットより先に使われます。 例えば、 DISCOUNT_POINT を100円分もっているお客さまが300円分の商品を購入しようとすると、100円分が優先的に DISCOUNT_POINT から消費され、他の残高等の決済手段から200円分が消費されます。 このように、お客さま目線では200円分の負担で300円分の決済ができた。という値引き体験を実現できています。 このような体験を実現するために、このプロジェクトではサービスの役割を分けました。冒頭の初期アーキテクチャ図は細かい実装寄りの図なので、ここでは大きく「決済前」「決済時」「決済後」の3つに整理しています。実際の構成にはさらに細かい処理や例外がありますが、大きな役割だけに絞ると次のようになります。括弧内は、社内で使っているサービス名です。 service 主な役割 特典付与サービス (santa-service) スタンプの管理、付与額の決定 支払い方法設定サービス (payment-config-service) お客さまの設定や状況ごとの決済手段を決定 決済サービス(payment-service) 渡された決済手段どおりに支払いを実施する 残高管理サービス(balance-service) DISCOUNT_POINT の残高管理、消費、失効、会計連携 このように役割を分けると、santa-serviceは条件判定、balance-serviceは残高の増減、payment-serviceは渡された内訳どおりに決済を成立させることに集中できます。 お客さまにはひとつの施策に見えるものを、裏側ではスタンプ、決済に使う残高やポイント、履歴表示、会計上の記録に分けて考えられるようになり、それぞれの影響範囲について議論や意思決定がしやすくなりました。 実際のキャンペーンの条件や例外の多くは、santa-serviceで扱っています。@hasegway さんの「 メルペイのキャンペーン基盤をルールベース汎用システムに書き直し、Otoku Revolutionするまでの話 」 では、このような複雑なキャンペーンをルール組み合わせとして扱えるようにした Rulebase 移行について紹介されています。 次の章から、付与、利用、返金・失効、会計 についての詳細をそれぞれ紹介していきます。 付与: 還元で決済を止めない Otoku Revolution、は決済完了後のスタンプ更新から始まります。 決済が完了すると、payment-serviceから非同期のeventが発行されます。santa-serviceはそのeventを受け取り、キャンペーンの対象のユーザーかどうかを確認してスタンプの付与を行います。ここで、スタンプが3回分に到達した場合は特典額を決め、balance-serviceに DISCOUNT_POINT の付与を行います。 ここで大事だったのは、 スタンプ更新や値引きポイント付与を、決済そのものの成功条件にしないこと です。 この還元プログラムの体験としては「決済後に即時でスタンプ・ DISCOUNT_POINT の付与が行われる」が理想です。一方で、スタンプ更新や DISCOUNT_POINT の付与までを決済処理の中で完了させようとすると、キャンペーン側の遅延や障害が決済成功率に影響してしまいます。 決済基盤では、決済そのものをキャンペーン側の都合で不安定にしないことが最優先です。そのため、決済完了後にあとからスタンプを更新し、条件達成時に DISCOUNT_POINT を付与する設計にしました。 利用: 最優先で使われるポイントとしてのDISCOUNT POINT 付与された DISCOUNT_POINT は、次の決済で自動的に使われます。 ここで設計上大事だったのは、payment-serviceの役割を広げすぎないことです。 直感的には、payment-serviceが残高を見て「 DISCOUNT_POINT があるなら使う」と決める設計も考えられます。この形にすると、値引きの判断を1か所に集めやすいという良さがあります。 しかし、payment-serviceは単に外部向けの決済を担当しているだけではなく、社内のありとあらゆるお金の流れに関与しており、できるだけ DISCOUNT_POINT に関係する専用のロジックは入りこまないようにしたいです。 そのため、決済前に支払い方法と金額を組み立てる payment-config-serviceが、支払い方法と DISCOUNT_POINT 残高を見て、 DISCOUNT_POINT を優先的に消費する決済内訳を作る設計にしました。payment-serviceはその内訳どおりに決済を進め、残高管理サービスが実際の残高消費を担います。 このような責務分担にすることで、コード決済等の決済手段ごとに値引きを適用するかどうかのルールを段階的決めることができるようにもなります。 返金・失効: クーポンとして振る舞うように設計 DISCOUNT_POINT は、通常のポイントのように貯め続けるものではなく、「スタンプがたまった次の決済で使える特典」です。 返金時や、決済金額が値引きポイントより小さいときも、不正対策やユーザービリティの観点でクーポンに近い挙動になるようにしました。 返金・失効では、主に次のように扱います。 返金時に、スタンプの数は戻さない 返金時に、使用済みの値引きポイントは再付与しない 決済金額より値引きポイントが大きい場合、使い切れなかった分は失効させる 例えば、1,000円の決済で100円分の値引きポイントを使ったあとに返金された場合でも、通常の返金処理の中でスタンプや値引きポイントの状態までは巻き戻しません。巻き戻す設計にすると、「再付与した値引きポイントをもう一度使えるのか」「スタンプも取り消すのか」「会計上は何を取り消すのか」を追加で扱う必要があり、スタンプカードとしてのルールも複雑になります。 また、500円分の値引きポイントを持っているお客さまが300円の決済をした場合は、300円分を消費し、残り200円は失効します。 DISCOUNT_POINT は残高として管理しつつ、次の決済だけで使われる特典に近い性質を持たせたかったためです。 このように整理することで、クーポンのような挙動のポイントである DISCOUNT_POINT を実現しています。 会計: お金の流れを先にデザイン DISCOUNT_POINT を新しい残高として扱う以上、アプリに表示して決済で使えるだけでは不十分です。付与された時点、使われた時点、失効した時点で会計上どう記録されるのかを説明できないまま進めると、後から設計を大きく見直すことになります。 そこで、まだ設計が固まりきる前に、プロジェクト全体の MoneyFlow、つまりお金がどう動くかを図にして、経理チームに相談しに行きました。 この図では、 DISCOUNT_POINT の付与・消費・失効が、会計上の記録につながることを描いています。全体ではなく一部だけを載せています。このプロジェクトでは早い段階からお金の流れを具体的な図にして議論していました。 実装が本格化する前に、お金の流れと会計上の意味を一緒に確認できたことは、後から振り返っても大きかったと思います。MoneyFlow そのものについては、@komatsu さんの 「 決済プラットフォームと経理を繋ぐ MoneyFlow 」に詳しくまとまっています。 Otoku Revolutionではこのように整理したうえで、 DISCOUNT_POINT の付与・消費・失効をbalance-serviceで扱い、会計連携までつなげて、会計レポートを作成できるようにしました。 リリース後、事前付与施策で効いた設計 リリース後には、多くのお客さまにこの還元プログラムを知ってもらうための施策も必要になりました。 そこで、対象のお客さまにあらかじめ DISCOUNT_POINT を付与する施策を追加で開始することにしました。キャンペーン開始直後のタイトなスケジュールの中、スピーディーに付与実施までもっていくことができました。 このような柔軟な追加施策を実施できたのは、単に実装が早かったからではありません。 DISCOUNT_POINT が残高管理サービスで管理される残高として扱われ、付与、消費、失効、会計上の記録の意味がすでに整理されていたことが効きました。 追加施策でも、付与する残高の種類、決済時の使われ方、使い切れなかった分の扱い、履歴での見え方、会計上の記録を新しく考え直す必要はありませんでした。「誰に、いくら、どの条件で付与するか」に集中できたことが、速度を出せたポイントかなと思います。 振り返り Otoku Revolutionでは、最初から「値引きポイントをどう見せるか」だけでなく、「決済では何が起きるか」「返金ではどう扱うか」「会計ではどう記録するか」をまとめて確認できたことが大きかったです。そのおかげで、 DISCOUNT_POINT を残高として付与し、決済で優先的に使うといアイディアで最後まで手戻りなくリリースまで実現できました。 個人的には、プロジェクトの構想段階から体験面の議論に混ぜてもらいながら、エンジニアとして実現性や整理が必要になりそうな点を少し先回りして確認しにいけたことは、私にとっても学びの多い進め方でした。体験を決めたあとに会計や法務、返金のルールに合わせて作り直すのではなく、最初からそれらを含めて設計できたことは大きかったと思います。普段 Balance Team で扱っている残高・会計・帳簿の知識が、お客さまに届く体験につながったことも印象に残っています。 決済基盤の仕事をしていると正しさや安全性を守る場面が多いですが、その知識の延長線上で新しい体験を実現できたことは、素直にうれしかったです。 次の記事は @kobaryo さんの「任意の単位での債権の色付けと与信の分割を実現する Debt View」です。私がこのプロジェクトに多くの時間を割いていた間、Balance Team の日々の業務を @kobaryo さんに大きく支えてもらっていました。次の記事もぜひ読んでみてください。
本記事は「 Merpay & Mercoin Tech Openness Month 2026 」の17日目の記事です。 この記事は新しいロールであるPdE (Product Engineer)というClient / Backend の境界をまたぐ「越境開発」ロールの取り組みについて、@anzai, @victoria Li, @ninnin, @panoramaの4名でお送りします。記事本文は、そのうちの1人である anzai が、自分の体験を一人称で振り返る形で書いています。 はじめに 「Client エンジニアが Backend を書き、Backend エンジニアが Client を書く」——そんな体制は実際に成立するのか。Q4 に試した小さな実験を紹介します。 ひとことでまとめると、Android エンジニアが Postgres のデータベース(DB)設計とサーバー実装を書き、Backend エンジニアが iOS/Android の画面を作りました。やってみて成立はしたものの、楽な道ではありません。何が効いて、どこで詰まったのか。本記事では「越境開発」というテーマに絞って、その実際を共有します。 越境開発の背景 私は普段メルペイでClient(Android / iOS)のチームを見ている Engineering Manager です。今回はManagerとしてではなく、自分の手でコードを書く一員として KYC(本人確認)領域のAI pod (小規模な開発チーム)に参加しました。 このチームでは、Product Manager(PdM)を置かず、エンジニアが1人で1プロジェクトを仕様策定からデリバリーまで一気通貫に持つ(1 Person 1 Release)、という体制を試しています。 そしてこの体制を回そうとすると、避けて通れない課題が1つ出てきます。それは「1人で End-to-End(E2E)に持つなら、Clientも Backend も自分で書くことになる」ということです。 KYC のプロジェクトは、ほとんどの場合 iOS / Android のClientと、サーバー側の API・DB の両方に手を入れます。従来はここをクライアント担当とサーバー担当で分けていました。1人1プロジェクトにするなら、その境界をまたぐ必要があります。E2Eで開発する中で「Client エンジニアが Backend も、Backend エンジニアが Client も開発できるか」を、実プロジェクトで検証することにしました。 いくつかのプロジェクトで Client-Backend を一気通貫で開発した結果、今期はどちらも成立しました。Client エンジニアがサーバーの DB と API を書いてリリースし、Backend エンジニアがモバイルの画面を出す。これ自体が、まず確かめたかったことです。 以下では、私がどう開発を進めたかを時系列で振り返り、そのあとで、どこで詰まったかを共有します。 AI を活用した越境開発の進め方 私はClientエンジニアです。今回担当したプロジェクトで最終的に必要としていたアウトプットは、具体的には2つでした。1つはお客さまに見せる UI/UX、もう1つは、その後の業務報告に使うレポートです。この2つの具体的なアウトプットから逆算して、「では、その裏側でどんな仕組みを実装しなければならないか」を決めていく必要がありました。そしてその仕組みの大部分が、私にとって初めてのサーバーサイド(Go / Postgres)です。 そこでまず、既存の実装を AI Agent に読み込ませ、「この2つのアウトプットを満たすには、どんな設計・実装が必要か」を AI に検討してもらうところから始めました。要件は手元にあるので、あとはそれを初めての言語と環境にどう落とすか。 この記事のここから先は、その道のりを時系列でたどります。 AI の説明もコードも、最初はわからない 最初にぶつかったのは、コードを書く以前の問題でした。AI Agent が返してくる説明そのものが、何を言っているのかわからないのです。 たとえば「Postgres を起動して、goose で既存の migration を流して、psql で確認して」と AI に言われても、最初は何を指しているのか、まったく理解できませんでした。goose とは何か、psql で何を確かめればいいのか ── そのひとつひとつが、Client 開発しかしてこなかった私には初めての言葉です。AI は正しいことを言っているのかもしれないけれど、こちらにそれを受け取る前提知識がない、という状態からのスタートでした。 ここでやったのは、ひたすら AI に問い返すことです。「それはどういう意味か」「図にして説明してくれ」と、いろいろなパターンで噛み砕いてもらう。そして自分が納得できたら、その理解を「こういうことですよね」と、今度は別のBackendエンジニアに確認しに行く。これを繰り返すことで、環境がどう動いていて、何をすればテストが回るのか、開発の前提条件をまず頭に入れていきました。 前提が見えてきたところで、いよいよ実装です。さきほどの要件をもとに「設計して実装してみて」と AI Agent にお願いし、コードを書いてもらいます。すると今度は、出てきたコードそのものが読めません。 なぜここで defer を呼んでいるのか。なぜ cancel() しなければならないのか。ここで context を受け取っているのは、つまりどういうことなのか。Go のイディオムが、ことごとくわからないのです。 そこで最初は、作ってもらったコードを一行一行、すべて AI に解説してもらいました。 context のキャンセルでリソースを解放しないと goroutine が漏れる、だからこの位置で defer cancel() する。そうした説明を1つずつ受けながら、文法書ではなく目の前の実コードを教材にして読み解いていきます。 なお、DB についてはまったくのゼロからではありませんでした。モバイルでも端末上の SQLite でテーブルを設計し、クエリを書く機会はあります。正規化やインデックスといった基礎概念そのものは持っていたので、サーバーの Postgres は「知っている概念を別のコンテキストに翻訳する」作業に近く、そこは助けになりました。 チャンクごとの並走型にたどり着く ただ、「全部書いてもらってから一行ずつ解説」では、どうしても効率が良くありません。これを変えたのが、チームのふりかえりでした。 私たちは週に1回レトロスペクティブを開き、チームの進め方を少しずつ改善していました。その中で、メンバーの一人が「チャンク(意味のある塊)ごとの並走型」というやり方を持ち込みます。AI に一気に全部を生成させるのではなく、意味のあるコードブロック単位で実装させる。そのブロックが「なぜ必要なのか」を自分が理解できればそのまま進め、理解できなければきちんと問い返す。「ここは A と B のやり方がありそうだけれど、なぜ A を選んだのか」と都度たずねていくことで、コードの一行ずつの意図を理解しながら前に進みます。 このやり方だと、巨大な生成物を上から順に読み下す必要がなくなり、開発の流れを「意味のある順序」で理解できます。機械的な行順ではなく、設計の意図に沿った順序で頭に入っていく感覚です。質問と回答はログとして残し、読み返せば「今日は何を学んだか」の復習にもなりました。 選んだ理由を残し、AI のコードを自分で理解する この進め方には、思わぬ副産物もありました。「いくつかの選択肢のなかで、なぜこの A を選んだのか」を開発の過程ですでに言語化しているので、それをそのまま Pull Request(PR)の説明に書けるのです。結果として、レビューはそれほど詰まることなく得られました。初めての領域でも、選択の根拠を添えられればレビュアーは判断しやすいですし、「ここまで考えた上でのPRなんだな」という信頼も得られます。ビギナーのPRを読むことはコードオーナーにとって大変な負担ですので、このような越境開発において信頼を作るための調査は必要な工程だったと思います。 ここで1つ、立ち止まって考えるべき論点があります。AI が書いたコードを、人間がわざわざ読んで理解する必要はあるのか。生成して、テストが通って、動けばよいのではないか。これはこれから重要になる議論だと思います。 私の現時点の答えは「理解すべき」です。 これからの生産性は、書いたコード量ではなく、意思決定の回数と質でほぼ決まっていくと考えています。そして意思決定の質と回数を上げるには、ドメインの理解が必要です。「このバックエンドの設計は、これで本当に正しいのか」を判断する場面で、いまのところ最終的な判断は人間がしています。その判断を下せるだけの理解を持っていないと、意思決定の回数も質も上がっていきません。だからこそ、AI が書いたコードでも中身を理解しておく価値がある、というのが今の立場です。 またAIの仕組み的に、永遠にそのPRに対して改善ポイントを考え出すことができます。たとえば、あるPRについて1回目のセッションでAIに聞くと A・B・C の3点を直すよう言われ、その修正結果を別のセッションで聞くと、今度は E・F・G の3点を直すよう言われる、ということが起こります。それを全部反映すると、最終的にはオーバーエンジニアリングした膨大なPRになりがちなので、現状は「どこまでが適切か」を人が判断する必要があるでしょう。 もしこの「理解する」のに2〜3年かかる長期投資なら、学ぶ理由はもっと厳しく問われるでしょう。ですが実際はそうではありませんでした。いまの AI Agent によるオンボーディングは本当にしやすく、いわば優秀な指導役が隣について、いつでもペアプロに付き合ってくれるような状況です。この環境であれば、3ヶ月もあれば、いま開発している領域の一通りの知識は得られるという手応えがありました。もちろん、もともとシニアエンジニアとしての経験がある、という条件付きではあります。投資回収が数年ではなく数ヶ月の単位に縮んでいるのなら、理解する側を選ぶのが合理的だと考えています。 設計の最終確認はシニアエンジニアと とはいえ、AI に聞けばそのまま採用、とはしませんでした。象徴的だったのがインデックスの設計です。 「このカラムにインデックスを張るべきか」を、AI の提案を鵜呑みにするのではなく、なぜ必要なのか/不要なのかを自分で根拠を持って調べました。どんなクエリパターンで引かれるのか、カーディナリティはどうか、読み取りが速くなるぶん書き込みコストとのトレードオフはどうなるのか。こうした観点を1つずつ確かめて、「この設計にはこういう理由がある」と説明できる状態にします。 それでも、最後はシニアエンジニアと一緒にレビューしました。正直に言えば、これは自分の自信のなさを埋めるためでもありました。AI が生成したものが本当に正しいのか、その最終的な判断が、私一人ではどうしてもつかなかったからです。 このレビューには、不安を消す以上の価値がありました。シニアエンジニアが、考え得るパターンをいくつも挙げてくれたうえで「では、この方法でいきましょう」と意思決定でき、いくつかの改善点も見つかりました。よりベターな やり方にたどり着くうえで、人と一緒に見てもらう工程はやはり必要だったように思います。こちらは Backend エンジニアが Client 開発をする場合も同様でした。 AI で下書きと理解を加速し、自分で根拠を固め、最終的には人とレビューする。この3段構えが、初めての領域で品質を担保し、かつ自分が安心して前に進むうえで、現実的なやり方でした。AI は出発点と伴走者としては非常に強力でしたが、設計の最終的な妥当性を見極め、選択肢を広げてくれるのは、まだ人間のレビューだった、というのが実感です。 なお、今回書いた DB 設計は2テーブルだけのシンプルな構成で、本格的な分散やシャーディングの考慮までは要りませんでした。テーブルが増え、複雑なインデックス設計が絡む規模で、AI とこの3段構えがどこまで通用するのかは、まだ検証できていません。「シンプルだったから回った」可能性は十分にあります。 最後に補足すると、こうして進められた背景には、組織図上で「越境させた」のではなく、同じチームの中で互いの領域をペアプロで教え合える環境があったことが大きいです。AI に聞いてもわからないこと、そもそも何を聞けばいいかわからないことは、隣にいる詳しいメンバーにその場で聞く。AI と人、両方に頼れる相手がいたことが、越境のコストを最も下げました。 つまずいたポイントと対策 ここまでは「どう乗り越えたか」を中心に書いてきましたが、実際には数多くの詰まりどころがありました。そしてその多くは設計や実装の難しさではなく、環境・ツール・運用、つまりコードを書く以外の全部にありました。Client しか書いてこなかった人間が Backend に入ると、ここでことごとく足を取られます。代表的なものを課題ごとに紹介します。 現状の開発スタイルがわからない 最初の壁は、開発スタイルそのものの違いでした。 Clientエンジニアの感覚として、まずローカルでビルドして動作を確認しようとしました。ところが、このコードをもともとメンテナンスしていたチームは、開発環境(dev)にまずデプロイしてから動かす、というスタイルを取っていました。つまり、ローカルの docker compose は、実のところほとんど使われていなかったのです。 私はそれを知らないまま、ローカルの docker compose を立ち上げようとして、「Postgres のバージョンが合わない」「環境変数が無効だ」と、動かない環境を前に四苦八苦しました。結果ローカルでテストできるようになったので良かったのですが、リポジトリ固有のこうした暗黙知は、AI ではどうにもならない部分です。 enum を一つ増やしたら、芋づる式にバージョンが上がる 次につまずいたのは、依存関係の更新でした。やりたかったのは、protobuf の定義に enum を一つ増やし、新しく追加された定数を参照することだけです。ところが、その定義を取り込もうと go mod tidy を実行したとたん、直接は触っていないはずのライブラリのバージョンが一斉に上がってしまいました。gRPC まわりから、最終的には Go 本体の要求バージョンまで動いてしまう。たった一つの enum のために、なぜここまで広がるのか、最初はまったく見当がつきませんでした。 やっかいだったのは、その「なぜ」に対して、もっともらしい説明がいくつも出てきて、しかもそれが間違っていたことです。AI に理由を聞くと、それらしい仮説、たとえば「余計な更新を巻き込んでいるだけだから、最小限に戻せる」といった答えを返してきます。ですが、その通りに戻そうとしても差分は収まりませんでした。 ひたすらAIと問答し、ようやく分かりました。原因は依存が構造的に噛み合っていたことでした。新しい生成ライブラリが新しい gRPC 系を要求し、それがさらに別の基盤ライブラリを引き、最終的に Go 本体の下限まで押し上げている構造だったのです。本当にそうなのかの依存関係のグラフを図示し、それでも自信が持てなかったのでPR Reviewでシニアエンジニアにレビューいただきました。 ローカルでのテストと Lint DB アクセス層(DAO: Data Access Object)のテストをローカルで回そうとすると、ただ実行するだけでは通りませんでした。テスト用の Postgres を専用ポートで立て、 PGPORT を明示的に指定する、といった、チームでは当たり前すぎて誰もドキュメントに書かない作法を、1つずつ踏みながら知っていきます。 たとえば DAO テストは、 5556 のような専用ポートでテスト用の Postgres を立て、 PGPORT でそこを向けて初めて通ります。これを知らないと、テストはローカルの既定のデータベースに接続しようとして、原因の見えないまま失敗し続けます。どこにも書かれていないこの一手にたどり着くまで、エラーメッセージとにらめっこする時間が続きました。 Lint も同様でした。ローカルで走らせると、設定のバージョン差(v1 系と v2 系の非互換)でうまく通らず、CI(Continuous Integration)に任せるという割り切りに落ち着きました。「ローカルで全部緑にしてから push する」という前提が、まず崩れます。 E2E 開発のマシン要件 1人で Client も Backend も1台で回すと、マシンへの負荷が一気に上がります。実際、Android ビルド+エミュレータ、iOS ビルド+シミュレータ、Docker コンテナ3つを同時に動かして、メモリを90GB使う場面がありました。スワップなしで E2E 開発をするなら、96GB は欲しいところです。 特にBackendのEngineerは元々高いスペックのPCを持っておらず、Client開発でまず最初のローカルビルドを試す段階で躓いてしまいました。ここからローカルビルドに耐えるPCを支給してもらうまでClient開発に着手できないということが起きてしまっていました。 越境して E2E を1人で持つということは、ツールチェーンも全部抱えるということでもあります。ハードウェアは目立ちませんが、無視できない条件でした。チーム内でも「越境する人ほどマシン要件が上がる」という前提を共有し、開発機の選定では多めのメモリを見込んでおく必要がある、という話になりました。 振り返って これらの課題に共通していたのは、詰まったポイントのほとんどが、設計や実装の難しさではなく、環境・ツール・運用の暗黙知だったことです。コードを書く力そのものよりも、その手前のところで足を取られていた、というのが実感でした。 ここで効いてくるのが、もともと人間のオンボーディングでも重要だった条件です。「DX(Developer Experience)が整っている」「オンボーディング資料や開発ドキュメントが整備されている」という、人にとっての整備状況が、そのまま AI にとっての整備状況でもありました。ドキュメントが薄く暗黙知に頼っている領域では、人も AI も同じように迷います。 今のところは、やってみて初めてわかる問題を踏んでは1つずつ潰していく、いわば「悲鳴駆動」で進めるしかなく、チームを立ち上げてから数ヶ月は生産性を上げるのが難しいのが正直なところです。逆に言えば、ここをドキュメントとセットアップスクリプトであらかじめ潰しておけば、越境のコストは大きく下げられる、という改善の的もはっきり見えました。次に越境に挑むなら、最初に着手するのはこの整備だろうと考えています。 まとめ KYC で試した範囲では、Client ⇄ Backend の越境開発は成立しました。Client エンジニアがサーバーを書き、Backend エンジニアが iOS/Android を出せています。 それを支えたのは、いくつかの条件でした。まず、小規模な開発チームとして一体で動き、ペアプロやオンボーディングセッションをしながら互いの領域を学べたこと。次に、AI Agent が初見の言語やコードを一文ずつ理解する伴走者になってくれたこと。ただし、設計の最終的な妥当性を見極め、そして開発者の不安を払拭するのは、いまも人間のシニアレビューでした。環境構築やツールのバージョン、ローカルテストといった暗黙知を埋めるには、オンボーディング環境とドキュメントが要ります。一方で、選択の根拠を開発の過程で言語化し、それを残しておけば、専門外の領域でもレビューは回りました。そして最後に、これらすべてを1台で回すには、メモリ96GB級の開発機が現実的に必要でした。 そして、これらが揃っていても、最初の数ヶ月は生産性が落ちます。越境は成長痛とセットでやってくる、というのが一番の学びでした。逆に言えば、その成長痛をチームとして引き受ける覚悟と、AI を含めたオンボーディングの仕組みがあれば、「クライアントエンジニア」「サーバーエンジニア」という肩書きの境界は、思っていたより動かせます。 次は、もっと複雑な DB 設計を含むプロジェクトで、AI がどこまで越境を支えられるのかを確かめていきます。エンジニア一人ひとりが領域をまたいで動けるようになることは、巡り巡って、より早く・より確かな価値をお客さまに届けることにつながると考えています。これからもこういった挑戦を続けていきたいと思います。 次の記事は yutaroさんです。引き続きお楽しみください。
こんにちは。Data Ingestion チームでData Engineerをしている @orfeon です。この記事は「 Merpay & Mercoin Tech Openness Month 2026 」の14日目の記事です。 はじめに Data Ingestion(旧Data Platform)チームでは、多数のマイクロサービスが管理する データベース・テーブル から、大量のデータを継続的にDWH(データウェアハウス)へ同期する必要があります。同期対象には数億〜 数百億件 に達する大規模なテーブルも含まれ、これらをいかに速く・安全に・一貫性を保ったまま抽出するかが、DWHの鮮度や安定性にとって大事になります。 これまで  Cloud Spanner  からのデータ取得では、Spannerの分散DB特有の機能(後述)を活用することで、大規模テーブルでも高いスループットでの取得を実現できていました。 一方、社内にはTiDBやAlloyDBといったSpanner以外のデータベースも多く利用されており、その中には数百億件以上に達するテーブルもあります。 これらのテーブルは従来、主キーなどで シーク方式 で取得していましたが、単一コネクションでの シーケンシャルなデータ取得 になるため、大規模テーブルでは取得に非常に時間がかかっていました。 そこで今回、Spannerと同じように、 それぞれのDBに特有の機能を活用して並列取得などでスループットを上げる よう工夫しました。 具体的には、 TiDB  と  AlloyDB  の大規模テーブルをDWHへ同期する仕組みを  Cloud Dataflow(Apache Beam)  上に構築しました。 本記事では、その中核となる2つのSourceモジュール   TiDBSource  と  PostgresSource   について、高いスループットを実現するための工夫を解説します。 なぜ汎用JDBCではなく専用モジュールなのか Beam/Dataflowには汎用的な  JdbcIO  が既に存在します。 しかし汎用JDBCは「 SELECT を実行して結果を1行ずつ読む」という標準的な経路をたどるため、大規模テーブルでは以下のボトルネックが発生します。 1行ごとのSQL処理オーバーヘッド : 通常のクエリ実行では、サーバ側でのタプルのテキスト/プロトコル変換などが行ごとに発生する。 並列化の難しさ : テーブルを並列に読むには「どこで分割するか」を決める必要があるが、 OFFSET ベースの分割はオフセットが大きくなるほど遅くなり、フルスキャンを誘発する。 一貫性の確保 : 並列に複数コネクションから読む場合、各コネクションが別々の時点を読むと整合性が崩れる。 そこで今回のモジュールでは、 それぞれのデータベースが持つネイティブなバルク転送機構と物理的なデータ配置情報を活用 し、汎用JDBCのボトルネックを回避する設計にしました。 加えて運用上の大きなメリットとして  分割キー(フィルタ条件)の自動抽出  があります。 マイクロサービスごとに膨大なテーブルを扱う環境では、テーブル1つひとつに対して「どのカラムで分割するか」を人手で指定するのは現実的ではありません。 両モジュールはテーブルのメタデータから 主キー(PK)や暗黙の行ID、物理ブロック位置を自動で見つけ出し 、分割範囲の絞り込み条件を組み立てます。 利用者は接続先とテーブル名を指定するだけで、同じ設定が多数のテーブルに横展開することができます。 なぜCloud Spannerでは高いスループットでデータ取得が可能なのか 今回の設計の発想は、既にうまくいっていたSpannerからの取得方法を、TiDBやAlloyDBにも持ち込むことにありました。 そこでまずSpannerが大規模テーブルでも高いスループットを出せている理由を説明します。 Spannerは分散データベースとして、以下の機能を組み合わせています。 PartitionQuery / PartitionRead(Splitベースの自動分割) : Spannerはデータを内部的に  Split (キー範囲+負荷ベース)へ分割して保持しています。 PartitionQuery  はこのSplit境界に基づいてクエリを複数のパーティションに自動分割します。クライアントはキー範囲などSplitの内部構造を意識する必要がありません。 BatchReadOnlyTransaction(スナップショット一貫性) : 全パーティションの読み取りが、 TimestampBound  で指定した同一スナップショットを参照することを保証します。ロックを取らずに一貫した読み取りができます。 Partition Tokenの分散・並列実行 : 分割結果は シリアライズ可能なPartition Token として返されるため、複数プロセス・複数マシン、そして Beam Worker に配布してそのまま並列実行できます。Apache Beamの  SpannerIO  も内部でこの仕組みを使っています。 Partition Tokenによる自動バージョン保持 : Tokenが有効な間は対象バージョンがGCされないことが保証されるため、クライアント側で明示的なバージョン保護(SafePoint管理)が不要です。 Data Boost(Spanner固有) : Google管理の独立した計算リソースで読み取るオプションで、 本番ワークロードへの影響をほぼゼロ にしつつ弾力的にスケールできます。 これらは「 物理的なデータ配置に沿った自動分割 」「 スナップショット一貫性 」「 分割単位の分散ワーカーへの配布と並列実行 」という構図で成り立っています。Spannerではこれらが高度に抽象化されたAPIとして提供されていますが、 TiDBやAlloyDB(PostgreSQL)にもそれに近いDB固有の機能が存在します 。 このSpannerの機能とTiDBやPostgreSQLの機能は以下のように対応します。 Spanner TiDB(dumpling相当) AlloyDB(PostgreSQL) PartitionQuery(Split境界で自動分割) TABLESAMPLE REGIONS() (TiKV Region境界) ctid 物理ブロック範囲( pg_relation_size ) BatchReadOnlyTransaction(スナップショット) tidb_snapshot MVCC(Multi-Version Concurrency Control) + TSO(Timestamp Oracle) バッチ読み取り( ctid スナップショットのズレは許容) Partition Tokenの分散実行 Range条件の分散実行(本記事の設計) Range条件の分散実行(本記事の設計) Partition Tokenによる自動GC保護 tidb_gc_life_time  の引き上げで代替 (該当なし) SpannerのSpannerIOで提供されている「分割 → 配布 → 並列スナップショット読み取り」を、TiDB/AlloyDBではDB固有の機能を組み合わせて自前で構築する、というのが本記事のモジュールの狙いです。 以降その共通の仕組みと各DB向けの実装を見ていきます。 共通アーキテクチャ 両モジュールに共通する基本戦略は次の3ステップです。  TiDBSource / PostgresSource はCloud Dataflow バッチジョブとして実行され、以下3つのステップで役割が分かれます。 テーブルの範囲分割 : 1本のコネクションでメタデータだけを取得し、テーブルを物理的な分割単位(Range)のリストに列挙する 再分配 : 分割単位をPCollectionの「種」として撒き、 Reshuffle でワーカーに再分配する 並列読み込み : 各ワーカーが担当Rangeをネイティブのバルク転送機構で並列に読み取る 以降、TiDBとPostgreSQLそれぞれについて、この3ステップの中身を掘り下げます。まずTiDBから、この3つのステップがどのように実装されるかを見ていきます。 TiDBテーブルからのデータ抽出 TiDB公式ツール dumpling に学ぶ TiDBには  dumpling  という高速なエクスポートツールが公式に提供されています。 TiDBSource の設計は、このdumplingが高スループットを実現している仕組みを参考にしています。 まずはdumpling側の要点を整理します。 テーブルのチャンク分割と並列読み取り dumplingは、1テーブルを丸ごと1クエリで読むのではなく、テーブルをチャンク(範囲)に分割し、各チャンクを独立したSELECTクエリとして並列実行します。 チャンク分割は3段階のフォールバック構造になっています。 戦略 方式 概要 A(最優先) TiKV Regionベース分割 TABLESAMPLE REGIONS()  でRegion境界をチャンク境界にする B(フォールバック) 数値インデックスベース分割 数値型PK/インデックスのMIN/MAXから均等分割 C(最終) テーブル全体ダンプ 分割可能なフィールドがない場合は1クエリ 特に重要なのが戦略Aです。 TiDBではデータがTiKV上で Region(デフォルト96MB単位) に分散配置されます。 dumplingはこのRegion境界をそのままチャンク境界として利用するため、各チャンクが異なるTiKVノードへの読み取りリクエストに分散され、クラスタ全体のI/O帯域を引き出せます。 dumplingの並列実行の仕組み: Producer-Consumer 分割したチャンクを並列に読み出すために、dumplingは内部で Producer-Consumer という構造をとります。登場人物は次の3つです(いずれもdumplingの実装に出てくる用語です)。 Producer(プロデューサ) : テーブルをチャンクに分割し、「このチャンクを読め」というタスクを作り続ける係。dumplingではメインのgoroutineが担当します。先ほどのRegion境界などをもとにタスクを生成します。 Writer(ライター) : 生成されたタスクを受け取り、実際にSELECTを発行してデータを読み出す係。 --threads で指定した数だけ並列に動き、それぞれが独立したDB接続を持ちます。タスクを消費するConsumer側にあたります。 infiniteChan(無制限チャネル) : ProducerとWriterの間をつなぐ、容量に上限のないキュー(待ち行列)。Writerの処理が詰まってもProducerがブロックされず、生成済みのタスクをいくらでも貯めておけます。 このように、タスクを作成する人(Producer)とタスクを実行する人(Writer)を分離し、その間を待ち行列(infiniteChan)でつなぐことで、分割と読み取りを互いに待たせずに並列で回す基本構造です。後述のTiDBSourceは、この役割分担をそのままDataflowの分散モデルに置き換えています。 Snapshot読み取り dumplingはTiDBのMVCC (Multi-Version Concurrency Control)機構を利用し、特定のTSO(Timestamp Oracle)時点の  スナップショット  から一貫したデータを読み取ります。 ロック不要 :  FLUSH TABLES WITH READ LOCK  のような排他ロックが不要で、書き込みをブロックしない。 一貫性保証 : 全Writerが同一時点のデータを読むため整合性が保たれる。 高スループット : ロック競合がないため並列度を上げられる。 加えてdumplingは、長時間のダンプ中にTiDBのGC(Garbage Collection)がスナップショット時点の古いバージョンを回収しないよう、PD(Placement Driver)に対してGC SafePointを登録します。 TiDBの機能を活用したDataflowでの実装 TiDBSource は、dumplingのこれらのアイデアを Apache Beam / Dataflowのモデルに移植 したものです。dumplingがgoroutineで実現していた並列性を、Dataflowの分散ワーカーによる並列性に置き換えています。対応関係は次の通りです。 dumpling TiDBSource (Dataflow) Producerがチャンクタスクを生成 パイプライン構築時に Range のリストを生成 infiniteChan  + 複数Writer goroutine Reshuffle  +  ParDo による分散ワーカー並列処理 各Writerが独立DB接続でSELECT 各ワーカーが @Setup で独自コネクションを確立 TSOスナップショット読み取り TSOを一度取得し全ワーカーに配布 ステップ1: 分割キーの決定とRangeの列挙 パイプラインの初期起動時に1本のコネクションを張り、出力スキーマの確定・スナップショットTSOの取得・テーブルの分割を行います。 ここではメタデータと境界値だけを読み、実データのスキャンは行いません。 分割キーの自動解決  は次の優先順位で行われます。利用者がカラムを指定しなくても、テーブルのメタデータから自動的に決定されます。 利用者が  splitField  を明示指定していればそれを使う なければ 単一カラムの主キー(PK) それもなければ 暗黙の行ID  _tidb_rowid (クラスタードキーを持たないテーブル向け) _tidb_rowid  は、明示的な主キーを持たないテーブルでTiDBが内部的に振る暗黙の行IDです。 これを分割キーに使えるため、主キー設計に依存せず、どんなテーブルでも分割の足がかりを得られます。 Rangeの列挙  は、先述のdumplingの戦略A→B→Cと同じ3段階フォールバックで行います。 戦略Aは、次のSQLでTiKVのRegion境界を取り出します。 SELECT `pk` FROM table TABLESAMPLE REGIONS() ORDER BY `pk` TABLESAMPLE REGIONS() は各Regionの先頭行を返すため、結果の各値が「次のチャンクの下限」になります。 境界値の列 b[1], b[2], …, b[n] から、隣り合う境界で挟まれた半開区間を生成します。 取りこぼしを防ぐため、最初の区間は下側を、最後の区間は上側を開いておきます。 chunk[ -∞, b[1] ), chunk[ b[1], b[2] ), …, chunk[ b[n], +∞ ) TABLESAMPLE REGIONS() はTiDB v5.0以降の構文です。 非TiDBのMySQLや古いTiDBではこのクエリが失敗するため、自動的に戦略B(数値MIN/MAX均等分割)へフォールバックします。 戦略Bは、SELECT MIN(pk), MAX(pk) で取得した範囲を、推定行数とチャンクあたりの目標行数 splitSize から決めた個数で等分します。 chunks = ⌈ 推定行数 / splitSize ⌉ step = (max − min) / chunks + 1 区間 = [min, min+step), [min+step, min+2·step), … , [ …, max] (stepの計算では厳密な切り上げ ⌈(max−min)/chunks⌉ ではなく+1 としています。半開区間 [cutoff, cutoff+step) で走査するため、割り切れるケースでもmax が最終チャンクに確実に含まれるようstep を 1 大きく取っており、実際のチャンク数は chunks 以下になります) ステップ2: Rangeの再分配(Reshuffle) 範囲が決まったら次にワーカーに範囲ごとの処理を並列にさせる必要があります。列挙した Range のリストを並列実行するよう明示的に指定するためにPCollection化した Range の後に、 Reshuffle.viaRandomKey()  を挟みます。  Reshuffle  には2つの重要な役割があります。 fusionの分断 : Dataflowは連続する処理を一つの処理Stageとして結合してしまうことがあります。これがないとDataflowは Create と後続の読み取り ParDo を融合し、Rangeが1ワーカーに偏って並列性が出ません。 ランダムキーによる再分配 : 全Rangeが利用可能なワーカーへ均等にばらまかれ、クラスタ全体に読み取り負荷が分散されます。 これがdumplingの「 infiniteChan から複数Writerがタスクを取り出す」構造に相当します。 ステップ3: 各ワーカーでのスナップショット並列読み取り 各ワーカーは処理開始時( @Setup )に自前のコネクションを確立し、パイプライン構築時に取得・配布されたTSOで  tidb_snapshot  をセットします。  全ワーカーが同一TSOを使うことで、分散読み取りでも単一時点の一貫したスナップショットになります 。 TSOの取得は、一貫スナップショットを開始してその時点の論理時刻を読むだけで完了します(トランザクション自体はすぐロールバックします)。 START TRANSACTION WITH CONSISTENT SNAPSHOT;SELECT @@tidb_current_ts; -- ← この値(TSO)を全ワーカーに配布 各ワーカー側では、読み取り前にセッション変数を設定します。 SET @@tidb_snapshot = '<配布されたTSO>'; -- ロックなしで同一MVCC版を読む SET @@session.tidb_enable_paging = ON; -- 大量スキャン時のメモリ使用量を抑制 tidb_enable_paging  はCoprocessorリクエストのメモリ使用量を抑える設定です(TiDB v6.2.0以降はデフォルト有効。変数を知らないDBではスキップ)。 実際の読み取りでは、担当Rangeを分割キーへの 値の範囲条件 に変換してSELECTに組み込みます。 SELECT * FROM table WHERE `pk` >= 1000 AND `pk` < 2000 ここで重要なのは、この条件が 主キー(インデックス)に対する値の比較 である点です。 データベースはこの行値比較を使ってインデックスを シーク し、該当範囲の先頭へ直接ジャンプして必要な行だけを読みます。  OFFSET のように先頭から数え直す必要がないため、どのチャンクも一定コストで読み出せます。 また、読み取りはMySQL Connector/Jの 行単位ストリーミングモード ( fetchSize = Integer.MIN_VALUE )で行います。 これは結果セット全体をワーカー側メモリにバッファせず1行ずつ取り出す特別な設定で、巨大チャンクでもワーカーのメモリ消費が一定に保たれます。 dumplingがgo-sql-driver/mysqlのストリーミング動作で実現しているのと同じ効果を、JDBC側で引き出している形です。 制約: GC SafePoint dumplingはPDクライアント経由でGC SafePointを登録しますが、これは JDBCコネクションからは到達できません。 そのためTiDBSourceではSafePoint登録を行わず、長時間の読み取りに対しては運用側でクラスタの tidb_gc_life_time を引き上げ、読み取り中にGCがスナップショットのバージョンを回収しないようにする運用方針としています。 AlloyDB(PostgreSQL)テーブルからのデータ抽出 AlloyDBとPostgreSQL互換性 AlloyDBは、Google CloudがPostgreSQL互換で提供するフルマネージドのデータベースサービスです。  ワイヤプロトコルもSQLの方言もPostgreSQLと互換 であるため、クライアントから見ればPostgreSQLそのものとして扱え、標準のPostgreSQL JDBCドライバがそのまま使えます。 さらに  ctid (物理行位置)や  COPY 、 pg_relation_size  といったPostgreSQLの内部機能・システムカタログも利用できます。 したがって、AlloyDBからのデータ抽出は  PostgreSQLの機能を前提に設計できます 。 以下では PostgresSource がPostgreSQLのどの機能を使っているかを説明しますが、その内容は基本的にAlloyDBにも当てはまります。 通常のJDBCクエリ取得との違い PostgresSource も「物理的な分割 → 並列読み取り」という骨格はTiDBと同じですが、データ転送経路とテーブル分割の方式 がPostgreSQL固有の機能に最適化されています。 まず、通常のJDBC経由のクエリ取得との違いを整理します。 観点 通常のJDBC ( SELECT ) PostgresSource ( COPY ... TO STDOUT BINARY ) 転送経路 拡張クエリプロトコル COPYプロトコル(バルク転送専用) 行ごとの処理 パース/プラン/結果整形が走りうる クエリは1回プラン。あとはタプルを連続送出 データ形式 テキスト or バイナリのフィールド単位 バイナリのタプルストリームを直接デコード 並列分割 OFFSET / LIMIT 等(大きいほど低速) ctid 物理ブロック範囲(フルスキャン不要) PostgreSQLの COPY は、もともと大量データのインポート/エクスポートのために用意された専用経路で、通常のクエリ実行に伴う1行ごとのオーバーヘッドを回避できます。 PostgresSource はJDBCドライバの CopyManager API(PGCopyInputStream)を使い、COPY (SELECT …) TO STDOUT (FORMAT BINARY) のバイナリ出力ストリームを受け取って、Avroのレコードへ直接デコードします。 中間のテキスト変換を挟まないぶん、CPU負荷とアロケーションを抑えられます。 PostgreSQLの機能を活用したDataflowでの実装 ステップ1: ctidブロック範囲の列挙 PostgreSQLの全タプルには ctid(物理的な行ロケーション = (ブロック番号, ブロック内オフセット))が付与されています。 PostgresSource はテーブルを 物理ブロック範囲 で分割し、各範囲を TID range scan で読みます。 分割の計画には実データのスキャンが一切不要です。 ブロック数は、テーブルの実ディスクサイズをブロックサイズで割って求めます。 SELECT pg_relation_size('table'::regclass) / current_setting('block_size')::bigint pg_relation_size  は実ディスクサイズを返すため、統計情報の  pg_class.relpages  推定値よりも正確で、しかもstat呼び出し1回で済みます。 1ブロックあたりの行密度は推定行数( pg_class.reltuples )から求め、目標行数  splitSize  に対応するブロック幅を機械的に算出します。  [0, blockCount)  をこの幅で割っていくだけなので、 フルスキャンも OFFSET も不要 です。 行密度 = 推定行数 / ブロック数1範囲のブロック幅 = max(1, round( splitSize / 行密度 )) 範囲 = [0, w), [w, 2w), … , [kw, blockCount) ※最後の範囲は上限を開く 最後の範囲を上限なし(オープンエンド)にしておくことで、分割を計画した後に追記された行も読み取れます。各範囲は ctid の半開区間条件に変換されます。 WHERE ctid >= '(0,0)'::tid AND ctid < '(3,0)'::tid この条件も、TiDBの主キー範囲と同様に物理位置の値による範囲比較です。 PostgreSQL 14以降ではこれが TID range scan として処理され、該当ブロックへ直接シークして必要な範囲だけを読みます。 注意点 :  ctid  は物理的な行位置なので、読み取り中にINSERT/UPDATE(別ページへの移動)/VACUUMが起きると、同じ行を二重に読んだり取りこぼしたりする可能性があります。同期中に更新されないテーブルに対して使うか、バッチ読み取りで一般的なスナップショットのズレを許容する前提で利用します。また、TID range scanはPostgreSQL 14以降のサポートで、それ以前のバージョンでは各範囲がシーケンシャルスキャンにフォールバックします。 ステップ2: Rangeの再分配 TiDBと同様、RangeのリストをCreateで撒き、Reshuffle.viaRandomKey()でワーカーへ再分配します。fusion分断と負荷分散の狙いは同じです。 ステップ3: 各ワーカーでのバイナリCOPY並列読み取り 各ワーカーは、担当Rangeのctid条件を組み込んだSELECTを COPY (…) TO STDOUT (FORMAT BINARY) でラップし、バイナリストリームを受け取ります。 COPY (SELECT * FROM table WHERE ctid >= '(0,0)'::tid AND ctid < '(3,0)'::tid)TO STDOUT (FORMAT BINARY) 返ってくるのはPostgreSQLのCOPYバイナリフォーマットです。 PostgresSource はこれを直接パースします。 先頭の固定シグネチャ( PGCOPY\n\377\r\n\0 )とヘッダを読み飛ばし、以降はタプルを1件ずつ読みます。 各タプルは「フィールド数」に続いて、フィールドごとに「長さ( -1 はNULL)+値のバイト列」が並ぶ構造で、終端は番兵値(フィールド数 =  -1 )で示されます。 値のデコードは、PostgreSQLのバイナリ表現をAvroの値へ型ごとに変換します。たとえば次のような処理です。 整数・浮動小数: ビッグエンディアンでそのまま読む numeric : base-10000 のdigit配列から十進数を復元 date  /  timestamp : PostgreSQLエポック(2000-01-01)基準の値をUnixエポック基準へ補正 uuid  /  json  /  jsonb  /  bytea : それぞれの専用処理 これらをドライバのテキスト変換を介さず自前でデコードすることで、転送経路を最短化しています。 なお、IAM認証(Cloud SQL / AlloyDB)にも対応しており、 user 未指定時はワーカーのサービスアカウントをDBユーザとして使い、接続URLに  enableIamAuth=true  を自動付与します。 検証用の12つのカラムを持つ6億件のダミーテーブルデータをAvroファイルとしてGCSに出力するタスクで、6コア並列で8分で処理完了するようになりました。 制約: なぜTiDBSourceのようにワーカー間の一貫性を担保できないのか TiDBSource では tidb_snapshot によって全ワーカーが同一時点を読み、ワーカー間の一貫性を担保していました。一方の PostgresSource では、各 ctid レンジが それぞれ独立したトランザクションで読まれる ため、レンジ間(別接続・別時刻)での一貫性は保証されません。読み取り中にINSERT/UPDATE( ctid が別ページへ移動)/VACUUMが起きると、レンジをまたいで行の重複や欠落が起こりえます。 PostgreSQLにも一見すると同等の仕組みがあります。 pg_dump の並列モードは、 pg_export_snapshot() でスナップショットをエクスポートし、各ワーカーが SET TRANSACTION SNAPSHOT でそれを取り込むことで、ワーカー間の一貫性を担保しています。 PostgresSource で同じことを今回実現できなかった理由は スナップショットの「寿命」の違い にあります。 TiDB( tidb_snapshot ) PostgreSQL( pg_export_snapshot() ) 実体 TSO(論理タイムスタンプ=ただの数値) エクスポート元トランザクションに紐づくスナップショットID 寿命 永続的 (GCされるまで。トランザクション非依存) 一時的 (エクスポート元トランザクションが開いている間だけ有効) 共有方法 数値を渡し、各セッションが独立に SET 元トランザクションが生存中に各セッションが SET TRANSACTION SNAPSHOT で取り込む PostgreSQLのエクスポートされたスナップショットは、 それをエクスポートしたトランザクションが終了するまでしかインポートできません 。 pg_dump の並列モードが成立するのは、単一プロセスのリーダーがダンプ全体の間ずっとエクスポート元トランザクションを開いたまま保持し、自身でワーカーを起動するからです。 PostgresSource が動くCloud Dataflowの実行モデルでは、元トランザクションを 並列読み取りの全期間にわたって保持する場所を確保するのが難しかったのです 。TiDBSourceではランチャーでTSO(数値)がトランザクションと無関係に有効だったためワーカーに配るだけで済みました。 そのため PostgresSource では各レンジ独立読み取りを前提とし、「同期中に更新されないテーブルに対して使う/バッチ読み取りで一般的なスナップショットのズレを許容する」という運用方針にしています。 まとめ: 高スループットを支える設計要素 両モジュールでは、 「テーブルの物理的なデータ配置に沿って分割し、その分割単位を分散ワーカーで並列に読み取る」  という共通する設計思想で実装しました。 以下  SpannerSource (SpannerのPartitionQuery等を活用したモジュール)も加えた各設計要素の比較表です。すでにいずれかのDBに親しんでいる人は別のDBの機能と比較することで関心・理解が深まるかもしれません。 要素 SpannerSource TiDBSource PostgresSource 物理分割の基準 Split境界( PartitionQuery ) TiKV Region境界( TABLESAMPLE REGIONS() ) ctid 物理ブロック範囲( pg_relation_size ) 分割キーの自動抽出 Spannerが自動分割(指定不要) PK /  _tidb_rowid  を自動解決 ctid (全テーブル共通) 分割計画のコスト API呼び出しのみ(フルスキャン不要) 境界キーの列挙のみ(フルスキャン不要) stat呼び出しのみ(フルスキャン・ OFFSET 不要) 範囲の読み取り Partition Tokenを実行 PK値の範囲比較でインデックスをシーク ctid の範囲比較でTID range scan 転送機構 gRPCストリーミング( executeStreamingSql ) ストリーミングResultSet( fetchSize=MIN_VALUE ) バイナリCOPY( COPY ... TO STDOUT BINARY ) 一貫性 BatchReadOnlyTransaction ( TimestampBound ) tidb_snapshot によるMVCCスナップショット バッチ読み取り( ctid スナップショットのズレは許容) 並列化 Partition Tokenを Reshuffle  +  ParDo で分散 Reshuffle  +  ParDo (dumplingのWriter並列に相当) Reshuffle  +  ParDo バージョン保護 Partition Tokenで自動保持 tidb_gc_life_time  の引き上げで代替 (該当なし) フォールバック Partition Query → 通常Query Region → 数値MIN/MAX → 全体 TID range scan → シーケンシャルスキャン(PG14未満) 汎用JDBCに対する優位性は、(1) 分割キーを自動抽出でき、分割計画も安価なので並列度を素直に上げられること、(2) 各ワーカーの転送がネイティブ機構でCPU/メモリ効率が高いこと、(3) DB固有のスナップショット機構で一貫性を担保できること(TiDB) の3点に整理できます。これらはもともとSpannerSourceがSpannerの機能で実現していた特性を、今回 TiDB / PostgreSQL 固有の機能を組み合わせて再現したものになります。 おわりに 多数のマイクロサービスが抱える大量のテーブルをDWHへ同期するという要件に対し、汎用的なJDBC経路ではなく、TiDBとPostgreSQL(AlloyDB)それぞれの  分散ストレージアーキテクチャや物理データ配置を活かした専用Sourceモジュール  をCloud Dataflow上に実装しました。これは、すでにSpannerからの取得で高スループットを実現できていた「分割 → 配布 → 並列スナップショット読み取り」というパターンを、他のDBにも横展開した取り組みと言えます。 今回は各DBが公式ツール( dumpling  / pg_dump )で利用されている高速化のノウハウを参考にさせてもらい、Dataflowの分散実行モデルに取り込みました。「分割キーの自動抽出」「物理配置に沿った安価な分割」「ネイティブなバルク転送」「DB固有のスナップショット」という要素の組み合わせが、大規模テーブルでも高いスループットと一貫性を両立させる助けになりました。 次の記事は cyan さんの「Scaling Myself: How I Run 22 Claude Code Sessions for DS4 Migration」です。引き続きお楽しみください。
p.fontbold{ font-weight: bold; } p.codeboxbefore{ margin-bottom:-70px; } @media screen and (max-width: 575px) { p.codeboxbefore{ margin-bottom:-13.4vw; } } この記事は Merpay & Mercoin Tech Openness Month 2026 の 13 日目の記事です。 こんにちは。Growth Platform Team でメルペイのポイント還元キャンペーン基盤である Santa サービスの開発を担当している @hasegway です。 なお、タイトルに登場する「Otoku Revolution」とは、コード決済を一定回数使うたびに必ず値引き体験が届く新企画のキャンペーン (本記事では「コード決済の回数連動キャンペーン」と呼びます) の社内呼称です。詳しくは本連載 17 日目の @yutaro の記事を楽しみにしていてください。本記事では、長く運用してきた Santa サービスをルールベースの汎用基盤 (以降「Rulebase 基盤」と呼びます) として書き直したリファクタリングの話と、新基盤の最初のキャンペーンとして「コード決済の回数連動キャンペーン」を立ち上げた話を取り上げます。順を追ってお話しする前に、まず Santa という基幹サービスがどのような歴史を経て、どんな負債を抱えるに至ったかについてお話しさせてください。 Santa の歴史 Santa という名前は、初期に 「使った翌日にバッチ処理でポイントを付与する」 サービスだったところから来ています。夜のうちに溜まったイベントを翌朝まとめて配って回る、シンプルな仕組みでした (初期の仕組みについては メルペイのキャンペーンを支えるサンタの秘密 が詳しいです)。 それから何年も経て、今では 1 日数百万イベントを処理し、メルカリ / メルペイのさまざまな利用シーンでポイント付与を行う基幹サービスへと成長しました。この成長の過程で一貫していたのは、 「キャンペーンの種別ごとに専用のイベントパイプラインを実装する」 スタイルで機能を積み重ねてきたことです。それぞれ独自のテーブル、独自のイベントハンドラ、独自の Cap (上限) ロジック、独自のポイント付与フローを持っていました。 2021 年のフィルタリング機能 は「複数の条件を AND/OR で組み合わせる」 発想を Santa に持ち込んだ、汎用化の最初の一歩でした。 2022 年のメルカード常時還元 は、その上に「メルカードステージ別の還元率」「複数月にまたがる Provision (引当金) 管理」 といった精緻なロジックを乗せた大規模なパイプラインで、現在でも Santa 最大のパイプラインです。それでも、キャンペーン本体のコードは CampaignType ごとに別物のままでした。 この構造は当初の要件においては合理的でした。しかしキャンペーン要件の複雑化とともに、コードの再利用性が低い設計による開発速度の低下、バグのキャンペーン別対応による運用負荷の増大といった課題が積み重なっていきました。2025年夏の時点では、Santa エンジニアチーム内でこれら課題への共通理解ができており、次世代キャンペーン構造のラフ設計まで進んでいました。ただし当時はスケジュール上の制約で本格着手を見送り、2025年10月にローンチしたメルカリモバイル向け特典キャンペーンは既存システムを拡張する形で実装しました。 その後、PoCを進め、2025年12月にチーム向けに成果を発表。2026年春にかけて、汎用キャンペーンテーブルにルール評価とアクション実行モジュールを組み合わせた Rulebase 基盤を新規に構築しました。現時点では「コード決済の回数連動キャンペーン」を1件稼働させている段階で、既存のキャンペーン群は引き続き従来の専用パイプラインで動いています。これら既存キャンペーンの段階的な移行は、これから先のフェーズです。 専用パイプラインが抱えた負債 Rulebase 基盤を作る前の Santa は、キャンペーンの種別(CampaignType) ごとに「専用パイプライン」を実装する、というスタイルで成長してきました。代表的なキャンペーン種別と、各種別の代表的なキャンペーン企画は次のとおりです。 キャンペーン種別 主なキャンペーン企画 購入時還元 「買ってお得!dポイント」など メルペイの定額払いの還元 「はじめての定額払いキャンペーン」など メルペイスマートマネーの還元 「初回利用」「カムバックキャンペーン」など メルカード還元 「メルカード常時還元」など これらの種別はそれぞれが、専用のテーブル群、決済や返済を受け取る Pub/Sub の入り口、ビジネスロジックを担う Interactor、ポイント付与履歴テーブルへの書き込みパス、付与上限の計算ロジックを抱えています。また、企画ごとの細かい要件の実現のため、基本のパイプラインの中にさまざまな分岐処理が加えられています。新たにキャンペーン企画を 1 本立てるたびに、これらのさまざまなレイヤーを個別に調査・変更しなければならない、というのが Santa の標準作業でした。 そして、長年運用するなかでこの構造がいくつかの負債を生んでいました。 累積した運用負債 まず、専用パイプラインを実装するスタイルでは、新たなキャンペーン要件のための追加開発が横展開して再利用しづらい問題がありました。また、当初に想定していたキャンペーン要件では考えられなかった新たな要件は、個別実装で対応せざるを得ないこともありました。これらは徐々に開発速度の低下やリグレッションテストの複雑化を招いていきました。特に MercardCampaignType ではこの問題が顕著で、リファクタリングに踏み切る直接的な引き金になりました。 MercardCampaignType が「なんでも置き場」化していった 2022 年にローンチした MercardCampaign パイプラインは、当初「利用ステージ別の還元率」と「清算起点のリアルタイムのポイント付与」を素直に扱うことを想定した、シンプルな常時還元キャンペーン向けの設計でした。 その後、メルカードまわりのキャンペーン要件は急速に広がっていきます。累計購入額連動キャンペーン、メルカリ NFT 決済への対応、メルカード ゴールド、メルカリモバイル契約者向けの特典など、どれも「メルカード保有者・利用者」という共通の文脈はあるものの、設計当時の想定にはなかった要件ばかりです。それでも置き場としては MercardCampaignType が最も適切だったため、これらの新要件は順次 MercardCampaignType の上に実装されていきます。本来シンプルに設計されていた入れ物に想定外の機能が次々と追加され、還元率算出や会計コード指定などの仕組みが本来の用途を超えて流用されるようになっていきました。 ここでは、特に歪みが目に見える形で表面化した3 つの事例 — 累計購入型 (2024 年 9 月)、メルカリ NFT 会計コード差し替え対応 (2025 年 12 月)、メルカリモバイル向け特典キャンペーン (2025 年 10 月) — を順に見ていきます。 累計購入型 と メルカリ NFT 会計コード差し替え 累計購入額連動キャンペーンでは、購入金額が複数のしきい値を順に超えるたびに段階的にポイントが付与されます (例: 累計購入額に応じて最大 P1,500 もらえる)。このキャンペーンが MercardCampaignType に投入され、本来の還元率スキーマに「購入累積トラッカー」型の挙動が乗りました。メルカリ NFT 会計コード差し替え対応では「キャンペーンの各種条件は他と共通にしつつNFT取引のみ会計コードを差し替える」という要件のため、コード内に分岐処理が追加され、キャンペーン設定値が複雑化しました。どちらもメルカード保有者向けの施策ではあるものの、当初想定の責務範囲を超えた要件です。 そして3 例目の、もっとも歪みが大きく出たケースが、メルカリモバイル向け特典キャンペーンです。 メルカリモバイル向け特典キャンペーン (2025 年 10 月) メルカリモバイル向け特典キャンペーンの要件は、 3 種類のメルカードステータス (保有無し / 通常版 / ゴールド) × 4 種類のモバイルデータプラン (4GB / 10GB / 20GB / 40GB) = 12 の独立したキャンペーンパターンが毎月必要、というものでした。 実装上は、本来メルカードステージ別の還元レートを保持するDBフィールドが「データプラン別のレート」の入れ物として流用されました。3 ステータス ×4 データプランの 12 組み合わせを、メルカード用テーブルの行として毎月生成する運用です。 また、お客さまのメルカードステータスやモバイルデータプランは日々変わりうるため変化に合わせて還元レートや月々の上限を計算し直す必要があります。そして、同じお客さまが同月内で別の組み合わせ向けキャンペーンにも二重にマッチして重複付与が起こりうる、というリスクも残ります。後者の重複付与を防ぐために、コード側にはこんな雰囲気のハードコードマップが入りました (簡略化したイメージ)。 // ポイント重複付与防止のため、キャンペーンIDをコードに埋め込み TemporaryCampaignIDMapping = map[string]string{ "202510": "campaign-id-1", "202511": "campaign-id-2", // 毎月追加が必要... そして各所に、データプラン別ステージを判定する if 文が散らばりました。 こうして、毎月 12 パターン分のデータ追加運用が必要な「Temporary」ハードコードマップが本番に居続け(Temporaryとは・・)、モバイル専用ステージを分岐させる if 文がポイント計算・フィルタ評価・API レスポンスに散在し、将来データプランが1つ増えるたびにコード変更とデプロイが必要になり、MercardCampaignType 全体のリグレッションテストも巻き込む、という構造が出来上がっていきます。当初は 「モバイルキャンペーンをアーキテクチャ刷新のきっかけにして抜本対応する」 計画もありましたが、ローンチ期日との両立が難しく、最終的に既存パイプラインを拡張する判断を取りました。当時の制約下では合理的な選択ですが、根本的な構造課題は持ち越し、運用負荷は増えています。 3 事例ともビジネス文脈では筋が通っている一方、「メルカード還元の入れ物」が「メルカード周辺キャンペーン全般の入れ物」として使われ、MercardCampaignType のスキーマと責務範囲が押し広げられてきたのが当時の状態で、そのひずみは無視できない大きさになっていました。 Rulebase 基盤の設計 このリファクタリングそのものは前から検討していたものの、ローンチ責任との両立が難しく、本格着手は半年寝かせています。その間も内部で PoC は進め、本実装で固めた方針は「キャンペーンの挙動を、専用コードから設定データへ」というシンプルなものです。 より具体的には、 「どのようなきっかけで動くか」 (TriggerType)、 「どのような条件でマッチさせるか」 (RuleCondition と、その評価を担う RuleEvaluator)、 「何をするか」 (ActionExecutor) という3 つの軸を、できるだけ atomic な (再利用可能なサイズの) 部品として定義し、その組み合わせで多様なキャンペーン要件に対応する、というのが基本的な発想です。従来のように「メルカードキャンペーン専用のロジックを書き下ろす」のではなく、Trigger / Condition / Action を小さな部品として用意し、キャンペーン定義はその組み合わせとして書く、という発想です。専用パイプライン時代との根本的な違いはここにあります。一度実装した Condition や Action を別の CampaignType から設定値だけで呼び出せたり、動的な条件分岐や繰り返し条件を1 つのキャンペーン定義の中で表現できたりする能力も、ここから生まれます。 全体像 チーム内発表でも、次の対比を使って説明しました。 As-Is (メルカード専用ハンドラの内部に if が積まれている) func (h *MercardCampaignHandler) Execute(event Event) { if user.Stage == StageA { if user.Status == "Active" { if !h.HasReward(user.ID, event.ID) { points = amount * 0.03 h.RewriteRewardHistory(user.ID, event.ID) } } } else if user.Stage == StageB { if user.Status == "Gold" { points = amount * 0.10 if h.HasReward(user.ID, event.ID) { h.RewriteReward(user.ID, event.ID, points) } } } } To-Be (キャンペーン定義は JSON データ、評価エンジンは汎用) { "rule_id": "mobile-std-4gb", "conditions": [ {"type": "user_attribute", "params": {"stage": "Standard", "plan": "4GB"}}, {"type": "period", "params": {"start": "2025-10-01", "end": "2025-10-31"}}, {"type": "payment_attribute", "params": {"transaction_type": "code_payment"}} ], "action": { "type": "ADD_POINTS", "params": {"rate": 0.05, "currency_points": 60, "monthly_cap": 200} } } この JSON を Spanner に永続化したスキーマが次の3 テーブルです。Campaigns 配下に CampaignRules、その配下に RuleConditions を INTERLEAVE IN PARENT で並べる構造になっています。 -- 大枠の宣言: 期間・CampaignType・キャンペーン固有設定 (JSON) Campaigns( CampaignID, CampaignType, StartAt, EndAt, Metadata, ... ) -- 1 キャンペーン内の複数ルール: 何をトリガに、どう集計し、どんなアクションを取るか CampaignRules( CampaignID, RuleID, TriggerType, CalculationType, ActionType, ActionParams (JSON), Priority, Enabled ) INTERLEAVE IN PARENT Campaigns -- 1 ルール内の複数条件: AND/OR グループで合成 RuleConditions( CampaignID, RuleID, ConditionID, ConditionType, ConditionParams (JSON), ConditionGroup ) INTERLEAVE IN PARENT CampaignRules これに対応する評価エンジン (Rule Evaluation Engine) を新設し、以下のような4 層構造の EvaluationData を入力として走らせます。 Layer 内容 Event Data 受信した Pub/Sub イベント Rule & Campaign DB から読んだ CampaignRule + RuleConditions User Data お客さまの属性 (カード種類、利用履歴など) Providers 外部サービスへの DI ハンドル イベントが届いてから付与までの流れは次のとおりです。 Condition/Rule と Action は、新しい種別が必要になったときに対応する実装を追加しておくことで、以降のあらゆるキャンペーン定義から再利用できる仕組みになっています。新規キャンペーンの立ち上げ自体は、すでにカタログに揃っているものの組み合わせで実現できるものであれば、コード変更を伴わずに SQL の INSERT で反映できます。 3 つの軸の中身 ここからは、前述した3 つの軸が Rulebase 基盤の中でそれぞれどう設計されているかを順に見ていきます。 TriggerType — どのようなきっかけで動くか CampaignRules テーブルの TriggerType 列が、各ルールが「どのイベントに反応するか」 を表します。 TriggerType は外部イベントと1対1になるよう定義しており、Pub/Sub から届いたメッセージを内部ドメインのモデルに正規化したうえで、合致する TriggerType を持つルール群を CampaignRules から引き当て、条件マッチングを担う2 段目のハンドラ層に引き渡すところまでが、この軸の責任範囲です。条件評価や副作用はここでは扱わず、後段に切り出しています。 RuleCondition と RuleEvaluator — どのような条件でマッチさせるか CampaignRules に紐づく RuleConditions テーブルには、評価したい条件が1行1 件 並んでいます。各レコードは次の3 つで構成されます。 ConditionType は、その条件がどんな種類の判定をするかを示す分類で、後段でどの ConditionEvaluator にディスパッチするかを決めます。 ConditionParams は、その ConditionType に渡す具体的なパラメータで、種別ごとに必要な引数が違うため、固定スキーマではなく JSON で柔軟に保持しています。 ConditionGroup は、複数の条件を AND と OR で組み合わせるためのグルーピングラベルで、これを使うことで A AND (B OR C) のような複合的な論理式を、フラットな行データで表現できるようにしています。 たとえば「ある期間内で、お客さま属性 B か C のいずれかにマッチしたら成立」 という A AND (B OR C) の条件は、RuleConditions テーブルに次のように 3 行で並びます。 ConditionID ConditionType ConditionGroup グループの意味 A period NULL 単独で AND B user_attribute g1 グループ g1 内で OR C payment_attribute g1 グループ g1 内で OR 評価エンジンの入力は、先に挙げた4 つのレイヤーの情報を1つに束ねた EvaluationData です。ConditionEvaluator 側からは「これさえ読めば判定に必要な値はそろっている」 状態で参照できるようにしてあります。PoC ではここにキャンペーン固有の計算結果も持たせる案を検討していましたが、本実装では「条件評価のフェーズとアクション実行のフェーズで責務を分けるべき」と判断し、 EvaluationData は条件評価に必要な情報だけに絞っています。 評価エンジン本体は、ConditionType ごとの個別判定を担う ConditionEvaluator と、ルール 1 件分の真偽をまとめる RuleEvaluator の 2 段構成です。 下段の ConditionEvaluator は、ConditionType ごとに 1 つずつ実装が用意されており、 EvaluationData を読み取ってその条件 1 件分の真偽を返します。判定は副作用を持たず、外部 API 呼び出しや DB 書き込みは起こりません。 上段の RuleEvaluator は、その上に乗ってルール 1 件分の評価を組み立てます。具体的には、(1) RuleCondition の各行をその ConditionType に応じた ConditionEvaluator に振り分け、(2) 各行が返した真偽を ConditionGroup のセマンティクス (NULL は単独 AND、同じ値どうしは OR、別の値どうしは AND) で AND/OR 合成し、(3) ルール 1 件分の最終的な真偽と、どの条件がどう寄与したかの内訳を返します。返るのは真偽と内訳だけで、計算結果や副作用は含めません。 新しい評価軸が必要になったときは、対応する ConditionEvaluator を実装して ConditionType の enum に登録します。一度カタログに加われば、以降のキャンペーン定義は RuleConditions の 1 行としてその軸を設定値ベースで呼び出せます。 ActionExecutor — 何をするか マッチしたルールに対応する Action を、 ActionType ごとの Executor が実行します。上限計算、ポイント付与ステータスの遷移、ポイント台帳への書き込み、外部へのイベント発行といった副作用は、ここで起こります。Condition / Rule 側は副作用を持たない設計なので、外部に作用するロジックはこの層に集約されます。新しい Action 種別が必要になったときは、対応する Executor を実装して ActionType の enum に登録します。一度カタログに加われば、以降のキャンペーン定義は CampaignRules.ActionType と ActionParams を指定する形で、その Action を汎用的に呼び出せます。 3 軸を支える共通ヘルパー Executor の責務は、Action ごとの副作用そのもの (DB 書き込み、外部 API 呼び出し、PointStatus の遷移など) です。一方で、ポイント計算と Cap (上限) 適用、PointStatus / ProvisionStatus のライフサイクル管理、外部マイクロサービスに渡す冪等キーの生成、設定値テンプレートの展開といった「Action 種別をまたいで毎回必要になる横断的な処理」は、3 軸とは別レイヤーの共通ヘルパーに切り出しています。各 Executor は、自分の Action に応じて必要なヘルパーだけを必要なときに呼び出す形にしてあります。実装したヘルパーはいくつかありますが、代表例を 2 つ挙げます。 PointLifecycleManager (PointStatus / ProvisionStatus のオーケストレーション) ポイント付与にまつわるライフサイクルをまとめて扱うヘルパーです。 お客さまへのポイント付与状態 (PointStatus) と、 会計上の引当状態 (ProvisionStatus) は、それぞれ独立したステートマシンとして表現しています。 上が PointStatus で、 planned で台帳に予定を立て、外部の付与 API が成功すると confirmed 、後処理まで終わると completed に進みます。 planned 直後にキャンセルされた場合は cancelled 、外部呼び出しが失敗した場合は failed に倒れる、というのが主な遷移です。下が ProvisionStatus で、引当が立っていない not_linked から、引当が紐づいた linked を経て、最終的に revoked (引当処理が確定した状態) に遷移するのが主軸です。 旧メルカードパイプラインでは、PointStatus の遷移と ProvisionStatus の遷移がそれぞれポイント付与処理側と引当処理側に分散して実装されていて、両者がどう連動しているかの見通しが悪くなっていました。Rulebase 基盤では、両方のステートマシンを PointLifecycleManager 配下に切り出し、 CompletePoint / ReversePoint / CreateProvision / UpdateProvision / RevokeProvision の各 Action から必要なときに呼び出す形にしてあります。これによって、複数月にまたがる引当が必要なメルカード系のキャンペーンも、引当が不要なシンプルなキャンペーンも、同じ部品の組み合わせでライフサイクルを表現できます。 TemplateExpander (設定値テンプレートの展開) 旧システムには汎用的な文字列テンプレートの仕組みがなく、たとえば会計コードを決済種別ごとに分けて積むような要件が出てくると、会計コードのパターン数だけキャンペーンレコードを別に切るしかありませんでした。本来 1 つのキャンペーンとして扱いたいものをパターン数だけ重複登録する必要があり、運用負荷の原因として積み上がっていた部分です。 これを汎用化するために用意したのが TemplateExpander です。キャンペーン定義のなかに {user_id} / {campaign_id} / {payment_count} といったプレースホルダーを書いておくと、実行時に EvaluationData から取り出した実値に置き換えます。会計コード (たとえば merpay_xxx_campaign-{campaign_id}-{user_status} ) や、ハッシュのシード文字列 ( {user_id}:{campaign_id}:{payment_count} ) などがその利用例です。新しいパターンが必要になったときも、設定マスタ側のテンプレート文字列を差し替えれば、コード変更や追加レコードなしに同一キャンペーンの中で扱えます。 最初の実装事例: コード決済の回数連動キャンペーン ここまでに作った汎用基盤の「入れ物」を使った最初の移行ですが、感覚的には「リスクが低く、運用負荷の高いところ」 から始めたくなります。新しめで蓄積データの少ないものほど移行リスクが下がり、運用負荷が高いほど移行の効果が出やすいからです。当初はメルカリモバイル向けキャンペーンを最初の移行対象に据える予定でした。元々これをアーキテクチャ刷新のきっかけにする案も挙がっていましたし、稼働開始から日が浅く蓄積データが少ない一方で 12 パターンの毎月運用で運用負荷が高い、というまさにスイートスポットの条件に当てはまっていたためです。 ただ、ちょうどそのタイミングで、新規企画として「コード決済の回数連動キャンペーン」 の話が立ち上がります。新規企画であれば、常時稼働している既存パイプラインを止めずに載せ替える、というコストを払わずに、最小構成で「設定値ベースでキャンペーンを組み立てる」 ことの検証ができます。結果として、最初の事例は移行ではなくゼロからの立ち上げに振り直し、本企画のキャンペーンを Rulebase 上で直接立ち上げる形で進めました。既存のメルカリモバイルなどの移行は、先のフェーズに持ち越しています。 この新規企画は、N 回利用するごとに付与が発火するシンプルな仕組みで、お客さまから見ると「使い続けるほど、確実に値引きが返ってくる」体験になります。 これをRulebase上で組むにあたって必要だったのは 累計カウントと周期報酬を扱う ActionType ( COUNT_AND_REWARD )を 1 つだけ追加することだけでした。あとは 既存の ConditionType (期間 / お客さま属性 / 決済属性) の組み合わせで実装できた点です。Rulebase 上で「ルール評価とアクション実行を最大限再利用し、なるべく設定値だけで組み立てる」形を、最初に検証できたケースになりました。 仕組み自体はシンプルです。お客さまのエントリ状況を「お客さま属性条件」で見て、対象決済を「決済属性条件」で絞り込んだうえで、 COUNT_AND_REWARD Executor がカウンタをインクリメントします。カウンタが規定回数に達するとポイントを付与してカウンタをリセットし、これをキャンペーン期間中ずっと繰り返す、という流れです。データとしては、Campaigns 1 行、CampaignRules 1 行、RuleConditions 数行、というシンプルな構造で表現できます (簡略化したイメージ)。 // Campaigns { "campaign_id": "...", "campaign_type": 9, // InfinitePayment "start_at": "2026-04-01", "end_at": "2026-09-30", "metadata": { ... } } // CampaignRules (1 件) { "trigger_type": "PaymentCharge", "action_type": "COUNT_AND_REWARD", "action_params": { "trigger_count": 3, "reward_currency_points": 100 } } // RuleConditions (3 件: 期間 / エントリ状況 / 決済種別) { "condition_type": "period", "params": { ... } } { "condition_type": "user_attribute", "params": { "attribute": "entry_status", "promotion_key": "..." } } { "condition_type": "payment_attribute", "params": { "attribute": "transaction_type", "value": "code_payment" } } 実装上の新規追加は COUNT_AND_REWARD Executor と、カウンタを保持する 2 つのテーブル (現在のカウントとログ) くらいです。他は基盤の汎用部品をそのまま組み合わせて完結しました。 補足として、この回数連動キャンペーンの意義は「単純なキャンペーン派生がやりやすくなった」ことではありません。SQL INSERT で似たキャンペーンを増やすこと自体は、従来の専用パイプライン時代でもそれなりにできていました。Rulebase 基盤で新しく可能になったのは、CampaignType をまたいだ Rule・Action の設定ベース再利用です。一度実装した Condition や Action は、別の CampaignType のキャンペーンからも設定値の組み合わせで呼び出せます。回数連動キャンペーンで使った COUNT_AND_REWARD も、他のキャンペーンが「累積回数で発火する」要件を持てば、コードを書き足さずに設定だけで使い回せます。 同じ仕組みは、動的な条件分岐を 1 つのキャンペーン設定で表現することにも転用できます。たとえばメルカリモバイル向けキャンペーンでは「3 ステータス × 4 データプラン」を 12 個の独立キャンペーン定義として毎月準備していましたが、Rulebase なら属性条件・繰り返し条件・上限条件を組み合わせて 1 つのキャンペーン設定の中で表現できます。「N 回ごとに発火」のような周期的な振る舞いも、 COUNT_AND_REWARD を Executor 側に持つことで汎用パーツとして扱えます。今後の新企画では、既存のルール・アクションの組み合わせで表現できる範囲が広がっているかぎり、ゼロからの実装ではなく「設定値だけで作って試す」ところから入れる、というのが新基盤の最大のメリットです。 余談: ランダムなのに、何度引いても同じ金額 仕様としては 「規定回数に到達したら、複数の候補金額から重み付きランダムで 1 つを引き当てる」 という要件があります。素直に math/rand で抽選してしまうと、Pub/Sub の at-least-once 配信下では同じ PaymentCharge が再配信されたときに 1 回目と 2 回目で別の金額が選ばれてしまうことがあります。PointID ベースの冪等性チェックは通っているのに、2 回目の試行から見える金額が初回付与額とずれる、というケースが起きうる構造です。 そこで抽選は乱数ではなく決定論的ハッシュで行いました。 user_id ・キャンペーン識別子・ payment_count を組み合わせた文字列をシードに、SHA-256 でハッシュ化したものから重み付きで候補を 1 つ選びます。アルゴリズムの概略は次のとおりです (簡略化したイメージ)。 // seed の例: "12345:campaign-abc:3" (user_id : campaign_id : payment_count) seed, _ := expander.Expand(params.DeterministicReward.SeedFormat) h := sha256.Sum256([]byte(seed)) v := binary.BigEndian.Uint64(h[0:8]) // 先頭 8 バイトを uint64 として取り出す var totalWeight uint64 for _, rv := range variations { totalWeight += rv.Weight } target := v % totalWeight // 重みの合計で割った余り (これが候補選択用の値) var cumulative uint64 for i, rv := range variations { cumulative += rv.Weight if target < cumulative { return rv, i // 累積重みで当たった候補を選択 } } お客さまから見た「使うたびに違う金額が返ってくる」体験はそのままに、同じ (user_id, campaign_id, payment_count) の組み合わせに対しては何度 retry が走っても、別プロセスから読まれても、同じ金額が一意に確定します。 そして、シードさえ揃えば抽選結果は確定値なので、Pub/Sub のイベント処理完了を待たずに「今回付与される金額」を計算できます。決済成功直後の API レスポンスやフロントエンド側で「今回は ○ ポイントです」と確定表示を返すことができ、後段で UserPoints が書かれたあとに金額が変わって見える、といった不整合の心配もありません。at-least-once と weighted random を両立させるための仕組みが、お客さまへの早い表示にもそのまま使える形になっています。 QA 環境の改善に助けられた話 今回の QAではいくつかの環境パターンを切り替えて実施する必要があり、工数ボリュームに不安を感じていました。ちょうど良いタイミングで今まで Santa サービスでは実現できていなかった「イベント駆動部分専用の QA 複製環境」をチームメンバーが作りきってくれたことで、並行して QA を実施できるようになり、大変助かりました。その詳細は本連載 8 日目の @mikupo の記事 で詳しく解説されています。 おわりに 今回のリファクタリングでは、「キャンペーン定義をコードからデータへ」 という方針のもと、3 テーブル + 評価エンジンに再構成しました。ローンチ責任との両立で半年寝かせていたリファクタリングの機会を諦めずに掴んで「入れ物」を作り切って本番でキャンペーンをひとつ稼働させたことで、システムに今後の新要件についての受け皿を作ることができました。 ただし、回数連動キャンペーン自体は本記事執筆時点ではローンチしたばかりで、まだ派生キャンペーンの実例はありません。設定だけで派生が立ち上がる世界の本格検証は、これからの新企画を通じて行っていくフェーズです。加えて、メルカード常時還元などの既存のパイプラインもまだ稼働しており、Temporary マップも本番に残ったままです。常時稼働するキャンペーンシステムでは、入れ物を作る難しさよりも、止めずに載せ替えるタイミングと手順の設計こそが、ここから先の本題になります。 今回の記事は以上になります。なにか参考になることがあれば幸いです。 次の記事は orfeon さんの「TiDB / AlloyDB の大規模テーブルを高速にBigQueryni同期するための工夫」です。引き続きお楽しみください。
DBRE(DataBase Reliability Engineering)チームの@coyamaとIDP(IDentity Platform)チームの@taskです。 メルカリでは2026年5月14日から2026年5月15日まで開催されたクラウドネイティブ会議にスポンサーとしてブース出展しました。2日間で125名にクイズへ参加いただき、想定以上に“認証”や“マイクロサービス規模”といった話題で多くの議論が生まれました。本記事では、ブースの展示内容やクイズやアンケートの結果に加えて、来場者とのやり取りや感想を紹介します。 メルカリブースの展示 メルカリの技術面の取り組みを参加者に認識いただくために、有志のエンジニアが中心となりブースを設計しました。メルカリのブースでは主に以下を展示しました。 メルカリの技術スタックのクイズ/アンケート メルカリのアーキテクチャ変遷やKubernetesクラスタでのパケットキャプチャに関するポスター AI SecurityのLLM Key Serverに関するスライド クイズ/アンケートに回答いただいた方には、メルカリのストラップやボールペン、マスキングテープをプレゼントしました。メルカリのストラップは、参加者から「メルカリらしいノベルティ」というコメントもあり好評でした。 メルカリのアーキテクチャ変遷のポスターでは、以下の転換点について展示をしていました。 データセンターからパブリッククラウドへの移行 アプリケーションのKubernetesへのデプロイ モノリシックアーキテクチャからマイクロサービスアーキテクチャへの移行 マイクロサービスアーキテクチャの課題とモジュラーモノリスの取り組み 「モノリシックアーキテクチャからマイクロサービスアーキテクチャに移行する際に、どうシステムを分割すべきか」や「マイクロサービスアーキテクチャの課題は何か」といった議論ができました。また、他社でもメルカリと類似した課題に直面していることを改めて認識しました。 ブースの展示内容に関連した資料は以下に公開されています。 SRE視点で振り返るメルカリのアーキテクチャ変遷と普遍的な考え – Speaker Deck It’s Not Always the Network (But Here’s How to Prove It): Kubernetes Packet Capture for SREs | USENIX LLM Key ServerによるLLM APIへの安全で便利なアクセス提供 | メルカリエンジニアリング メルカリのテックスタックに関するクイズ 今回のクイズは、メルカリが日々向き合っているサービス全体やテックスタックについて興味を持ってもらうべく、全4問で構成しました。特定のディープなインフラ技術に寄るのではなく、広めのプラットフォームエンジニアリングに関する話題について広くカバーしつつ、ユーザが実際に目にするプロダクト寄りの問題も含めてみました。 その結果、広めのテックスタックをカバーした中〜高程度の問題セットになりました。 そして、クラウドネイティブ会議中にて2日間で125名の回答をいただき、分布は以下の通りでした。中央値は2点で、4点満点を取ったのはわずか9名のみだったため、なかなか手応えのあるクイズになっていたと思います。 また、それぞれの回答の分布は以下の通りでした。 出題した問題の中で、特に注目を集めたのが以下のログイン認証方法に関する問題です。 メルカリで利用されているログイン認証方法の上位3つを使用頻度の高い順にあげてください。 What are the top three login authentication methods used in Mercari, ranked from most to least used? Google+SMS > Password+SMS > Facebook Password+SMS > Passkeys > Google+SMS Password+SMS > Apple > Passkeys Passkeys > Password+SMS > Email Link これはクイズ作成時の情報なのですが、この回答について「一般的な Web サービスをイメージすると、パスワードやソーシャルログインが上位を占めると思いがちなので意外だった」「パスキーの普及率が意外と高いのが面白い」といったコメントが多かったです。普通はこうだろうという先入観で答えると間違えてしまう問題だったので、メルカリが技術で攻めた良い事例なのかもしれません。 また、こちらのログイン認証方法について、メルカリは 2023 年からパスキーログインの普及率拡大を推進しているため、最新のメトリクスによると Passkeys > Password+SMS > Google+SMS に順位が変わっています。 メルカリ、すべてのログインに 生体認証「パスキー」を導入 | 株式会社メルカリ また、他にも管理されているマイクロサービス数に関する問題についてもコメントをいただくことが多かったです。 メルカリの microservices-terraform リポジトリでは、多数のサービス群がInfrastructure-as-Codeとして管理されています。その管理下にあるサービスの総数はいくつでしょうか? The microservices-terraform repo manages Infrastructure-as-Code for a large portfolio of services. What is the total number of services managed in this repo? 100 – 250 services 250 – 500 services 501 – 1000 services more than 1000 services 特に、「想定よりも多くのマイクロサービスを抱えているのに驚いた」「多くのマイクロサービスを抱える上での新たな課題は何か」と言ったコメントが多かったです。こちらの変遷については SRE視点で振り返るメルカリのアーキテクチャ変遷と普遍的な考え – Speaker Deck でも触れられている通りで、こちらの資料を肴にブースにおけるディスカッションが起きたのが面白い点でした。 いずれにせよ、どのクイズにおいても新しいディスカッションや雑談のネタになったため、このようなメルカリ独自のトピックにおける広めのクイズ設定は狙い通りでした。 Cloud Native Kaigiでのブース出展を終えて 今回のクラウドネイティブ会議では、本当に多くの方にメルカリブースへお立ち寄りいただきました。セッションの合間の休憩時間にはブース前が熱気に包まれる場面もあり、運営メンバー一同、クラウドネイティブ・コミュニティの圧倒的な熱量を直接肌で感じることができました。 (coyama) メルカリからもプラットフォームエンジニアリングに関わる複数のチームのメンバーが参加していました。そのため、参加者から寄せられた幅広い質問に対応できる体制で、2日間を終えることができました。例えば、DBREに関する質問では、「メルカリのテックブログでTiDB Auto Scalerの記事を読んで参考になった」という声をきっかけに、トラフィックパターンについて議論することができました。また、セキュリティに関する質問については、セキュリティエンジニアに回答してもらうことで、うまく役割分担ができていました。複数のチームからメンバーが集まることでプラットフォームエンジニアリングやSREに関する幅広いコンテンツを提供できたのは良かったと思います。 (task) ブース運営を通して特に印象的だったのは、今回の「テックスタッククイズ」が単なるエンタメで終わらず、 そこからディープな技術的ディスカッションへと発展したこと です。 例えば、正答率が低かったTerraformのMonorepoに関する問題をきっかけに、「実際、数百のサービスが相乗りしている状態で、CIの実行速度やデプロイの安全性はどう担保しているんですか?」「うちの会社でもプラットフォーム基盤をどう切り出すかで悩んでいて……」といった、現場のエンジニアならではのリアルな悩みや知見の交換が、ブースのあちこちで自然発生していました。 問題の難易度に「うわっ、ガチなやつだ…(笑)」と頭を抱えつつも楽しんでくださる参加者の皆さんの姿や、「メルカリのプラットフォームって、ここまでプロダクトの体験(パスキー等)に踏み込んで作り込まれてるんですね!」という驚きの声は、日々大規模なシステム基盤と向き合っている私たちにとって、何よりのモチベーションになりました。 社外の優秀なエンジニアの方々と直接お話しし、フィードバックをいただけたことは、チームにとっても非常に大きな刺激となりました。 改めて、ブースに足を運んでくださった皆様、そして難問クイズに挑戦してくださった125名の猛者の皆様、本当にありがとうございました! メルカリでは、今回出題したような「プラットフォームからプロダクトまで」を跨ぐような大規模で複雑な技術課題に、一緒に立ち向かってくれる仲間を絶賛募集しています。 「今回のクイズの内容、もっと詳しく聞いてみたい!」「自分ならもっと良いアーキテクチャにできる!」と興味を持ってくださった方、ぜひ以下のリンクからカジュアルにお話ししましょう!お待ちしています! 株式会社メルカリ 採用情報 7/8(水)14:00- Mercari AI Career Fes 2026 – connpass
はじめに こんにちは。メルペイQAチームでQA Engineerをしている  @um(うめ)  です。この記事は「 Merpay & Mercoin Tech Openness Month 2026 」の12日目の記事です。 コードレビューは品質を守る重要なプロセスですが、人間が行うレビューには限界があります。観点の漏れ、見落とし、初めて触るコードベースへの不慣れ、これらはチームが大きくなるほど顕在化する課題です。 私たちのチームでは、こうした課題に対処するため、Claude CodeのSkillsを活用したAIレビューの仕組みを導入しました。 この記事では、 /review-pr と /improve-review-pr という2つのコマンドを組み合わせた自己改善サイクルについて紹介します。 /review-pr:AIによるPRレビュー 概要 Claude Code には標準で /review というレビュースキルが組み込まれていますが、所属チームではこれとは別に、チーム固有の観点を盛り込んだレビュースキル /review-pr を独自に作成・運用しています。汎用的な観点だけでは拾いきれないポイントを、チームのコードベース/アーキテクチャに合わせて追加・調整できることが、独自運用を選んだ最大の理由です。 /review-pr は、PRの変更内容をAIが自動分析し、不具合リスクを多角的にレビューするスキルです。 /review-pr <PR番号> というコマンド一発で実行でき、以下の6つの汎用的観点を自動チェックします。 汎用的観点 チェック内容 コードの正確性 ロジックの誤り、nil参照、型の不一致、境界値での挙動など エッジケース・異常系 エラーハンドリングの漏れなど 後方互換性 API変更による既存の呼び出し元への影響 機能的影響 機能の喪失、拡張性への制約 テストの十分性 PRにおけるテストの追加・更新有無 Unit Testの網羅性 既存テストも含めた正常系・異常系・境界値のカバレッジ Extra観点:コードベース固有のチェック項目 上記6つは汎用的な観点ですが、 /review-pr スキルにはこれに加えて「Extra観点」と呼ぶセクションも存在します。このセクションには、チームのコードベース固有のアーキテクチャや設計パターンに基づいたチェック項目が蓄積されています。 具体的な内容はリポジトリの特性に依存するため詳細には触れませんが、汎用的なレビュー観点では検出しにくい「そのプロジェクトならではの見落としパターン」を防ぐための項目が並んでいます。このExtra観点こそが、後述する /improve-review-pr によって継続的に育てられていく部分です。 しかし、この仕組みを形骸化せず運用し続けるためには課題がありました。 課題:スキルは使い続けても賢くならない 当初の仕組みのままだと、過去の学び(見落としパターン)が仕組み側に蓄積されません。どれだけ /review-pr スキルを使い続けても、新しい不具合のパターンを自動で学習することはありません。 ある日、AIレビューをかいくぐる形で不具合が混入し、チームが修正PRを作成する必要が生じたとします。同じようなパターンの不具合が再発しても、スキルのファイルを手動で更新しない限り、AIは同じ見落としを繰り返します。 これは「人間が毎回ルールを追記しなければならない」という運用負荷を生みます。私たちがこの課題を解決するために作ったのが /improve-review-pr コマンドです。 /improve-review-pr:修正PRを糧にスキルを育てる 概要 /improve-review-pr <修正PR番号> は、対応漏れが発覚した修正PRを分析し、 /review-pr スキルにExtra観点として再発防止のチェック項目を追加するコマンドです。 修正PRが発生するたびに「なぜ元のレビューで見落としたか」を分析し、同じパターンの見落としを二度と起こさないようスキルを更新します。ユーザーの承認を経てから SKILL.md を更新する設計で、チームの知見を継続的に蓄積できます。 仕組み /improve-review-pr <修正PR番号> を実行すると、以下のステップが走ります。 Step 1:修正PRの情報取得 gh コマンドで修正PRのタイトル・説明・変更ファイル・差分を取得します。 Step 2:元PRの特定(任意) 修正PRの説明文から「この修正が対応した元のPR」を自動抽出します。特定できない場合はユーザーに確認し、不明な場合もそのまま分析を続行します。 Step 3:対応漏れの分析 「なぜこの変更が元PRのレビューで見落とされたか」を分析します。元PRが判明している場合は「元PRのどのファイル変更が、修正の必要性を示唆していたか」まで深掘りします。 Step 4:パターンの抽象化と分類 個別の事例を汎用的なチェックパターンに変換します。以下の基準で追加先を判断します。 分類 条件 追加先 リポジトリ固有 コードベース固有のアーキテクチャに起因 リポジトリ固有チェックとして追加 汎用 一般的なレビュー観点で防げる 既存の観点セクションに追記 既存チェックの強化 類似するチェックが既に存在する 既存項目を拡張 Step 5:重複チェックと提案 /review-pr のSKILL.mdを読み込み、類似チェックの有無を確認した上で改善提案します。承認を得てから更新します。 Step 6:SKILL.mdの更新 ユーザーの承認後、スキルファイルを更新し、変更箇所をサマリとして表示します。 この2つのコマンドを組み合わせることで、以下の自己改善サイクルが回り始めます。 自己改善サイクルの全体像 先週マージされたPRから候補を検出するため、週次でGitHub Actionsが実行され、候補が存在する場合、Slackに通知します。 チームメンバーはその通知を見て /improve-review-pr を実行することで、レビュースキルに改善が1つ積み上がります。 「修正PRを都度見逃さずスキルに反映する」という運用負荷を自動化によって低減した、継続的なフィードバックループです。 プロセス設計のポイント:Human-in-the-Loop 完全自動化も技術的には可能ですが、あえて「ユーザーの確認・承認を経てから更新する」設計にしています。理由は2つあります。 1.誤ったパターンの混入を防ぐ AIが導出したパターンが常に最適な状態とは限りません。 チェック観点がもう一段階抽象化が必要だったり、チームの開発方針に対して妥当ではない場合もあります。人間が内容を確認することで、このような不適切なチェック項目が追加されてしまうリスクを低減しています。 2.チームの知識として定着させる 承認プロセスを通じることで、「なぜこのチェック項目が追加されたか」をチームメンバーが意識する機会が生まれます。 単なるルールの羅列ではなく、背景のある知識として蓄積されていきます。 まとめ Claude Codeのスキル機能を活用することで、AIレビューを「固定されたツール」ではなく、チームの知見を蓄積し続ける仕組みとして運用できるようになりました。今後さらに多くの修正PRを経てスキルが充実していくことを期待しています。 同様の仕組みに興味がある方の参考になれば幸いです。 次の記事は hasegway さんです。引き続きお楽しみください。
はじめに こんにちは。Merpay の Payment & Customer Platform で会計システムを開発・運用する Accounting チームで Backend Engineer をしている @mewuto です。本記事は「 Merpay & Mercoin Tech Openness Month 2026 」の11日目の記事です。 マネージドサービスの世代移行では、コードを変えていなくても、デフォルト値の違いだけでシステムの振る舞いが変わることがあります。ある月末の早朝、Cloud Functions の世代移行が引き金となり、会計イベント基盤で Cloud Pub/Sub のメッセージが大量に滞留して、新規の会計データを Cloud Spanner に登録できないインシデントが発生しました。 直接の引き金は、Cloud Functions を 1st gen から 2nd gen(Cloud Run ベース)へ移行した際に --max-instances を明示しておらず、Cloud Functions のスケール上限が「無制限」(1st genのデフォルト)から「100」(2nd genのデフォルト)へ下がっていたことでした。しかし、障害がここまで大きくなったのは、この引き金だけが原因ではありません。本記事では、1000万件規模に達したこの滞留がどのように構成されていたのかを解き、Cloud Run と Spanner、そして監視の各面で私たちが講じた対策を紹介します。 1. 月末ピークを支える会計イベント基盤 まず、私たちが運用する会計システムの全体像を説明します。会計システムは、社内の各マイクロサービスが発行する「会計イベント」を集約し、Cloud Spanner に記録する基盤です。この会計データは送信元サービスとの突合(リコンサイル)を経て、加盟店への精算や月次の会計締めといった後続処理の前提となるため、取りこぼしや遅延がそのまま業務影響に直結します。 会計システムは、次のような非同期パイプラインでイベントを受け取ります。送信元から Spanner までは、おおむね一方向の流れです。 この構成には、今回の障害を理解するうえで重要な特性が2つあります。 1つ目は、すべてが非同期で動く点です。送信元マイクロサービスはイベントを発行したら応答を待たず、配信・リトライ・整合性の担保はすべて Pub/Sub 以降のパイプラインが引き受けます。特に Pub/Sub から Cloud Run へは push 型サブスクリプションで配信されるため、消費側である Cloud Run とその書き込み先である Spanner の処理能力が、そのままパイプライン全体のスループット上限になります。 2つ目は、負荷が月末に集中する点です。業務特性上、月末・月初の締めに合わせてトランザクションが一気に押し寄せ、平常時の数十倍のスパイクが発生します。 なお、Pub/Sub に届いたメッセージは、この push 経路とは別の pull 型サブスクリプションを通じて Dataflow でも読み取られ、並行して Cloud Storage(GCS)に保存されています。本処理の滞留に巻き込まれないこのGCS上のデータを使って後の復旧を行いました。 2. 1000万件の滞留はどのように発生したか インシデントは、ある月末最終営業日の早朝に発生しました。6時ごろから Pub/Sub のメッセージが滞留しはじめ、Spanner への新規登録が滞り、新規の会計データを受け付けられない状態が断続的に続きました。復旧と再発を繰り返し、収束までには丸一日以上を要しました。 データロスそのものは、先ほど触れた GCS上のデータから再投入することで回避できました。しかし会計システムでは、送信元サービスと DB の間でリコンサイルが完了したものだけが、加盟店精算や月次の会計締めといった後続処理の対象になります。Spanner に登録されなかったデータはリコンサイル未完了のまま残り、締め日と重なったことで、広範な業務影響につながりました。 この滞留が深刻なのは、一度始まるとインフラリソースの上限でスケールが頭打ちになり、処理が遅れるほど次の処理も遅れる悪循環に陥るからです。会計イベントが高い密度で連続して到着すると、maxInstances=100 で頭打ちになった Cloud Run はこれを処理しきれません。上限まで張り付いた Cloud Run が一斉に書き込むことでSpanner CPUが逼迫し、1件あたりの書き込み時間が延びます。すると、処理スループットがさらに落ち、滞留はさらに進んでしまいます。やがて滞留時間が eventMaxAge(メッセージを破棄するまでの最大保持期間)を超えると、メッセージは Spanner に登録されないまま破棄されてしまいます。 3. きっかけは 2nd gen 移行時のデフォルト値 滞留の原因を調べていくと、今回の障害の1ヶ月以上前に行った Cloud Functions の世代移行に行き着きました。私たちは関数を 1st gen から 2nd gen(Cloud Run ベース)へ移行しましたが、このときデプロイコマンドで --max-instances を明示しておらず、世代によってデフォルト値が変わることを、十分に意識できていませんでした。 この「明示しなかった」ことが、スケール上限を静かに引き下げていました。下の表のとおり、1st gen ではスケール上限が実質無制限だったのに対し、2nd gen のデフォルトは 100 です。 Version デフォルトの max instances 1st gen 無制限(プロジェクト Quota の範囲) 2nd gen 100 コードもデプロイスクリプトも変えていないのに、移行しただけで上限が実質無制限から 100 へ下がっていたことになります。平常時は 100 インスタンスで十分だったため、移行後しばらくは問題が表面化せず、私たちの体感としては、ある月末に突如として障害が発生したように見えました。しかし実際は、監視がなかったために気づけていなかっただけでした。インシデント後に振り返ると、上限に迫っていた月もあり、水面下ではすでに限界の兆候が出ていたのです。 4. 障害を大きくした複数の要因 --max-instances の指定漏れは引き金ではありましたが、それだけでこの障害を説明することはできません。調べてみると、2nd gen移行後に迎えた月末のピーク時でも条件はほとんど同じだったからです。maxInstances は 100、eventMaxAge も同じ 21分で、トラフィック総量も3時間で約17〜19GBとほぼ変わりませんでした。 それでも、そのときは障害は起きていませんでした。決定的に違ったのは、トラフィックの「連続性」です。合間に負荷が落ち着く「谷」があり、その短い谷の間に Spanner CPU と未 ack の蓄積がリセットされていました。ところが障害当日は、この谷がないままピークが約40分間続き、システムは回復の契機をついに得られませんでした。 整理すると、今回の障害は3つの要因が連鎖して成り立っていました。まず maxInstances=100 という処理上限が滞留を発生させ、次にトラフィックの連続性がその滞留を固定化し、最後に eventMaxAge によるメッセージのドロップ、という連鎖により実害となりました。いずれか1つでも条件が違えば被害の規模は抑えられたはずで、だからこそ私たちは対策を1箇所に絞らず、制御できる Cloud Run と Spanner の両方に講じることにしました。 5. Cloud Run の処理能力を引き上げる 最初に着手したのは、直接の引き金となった Cloud Run のスケール上限です。あわせて、1インスタンスあたりの処理効率も見直しました。本番に反映した設定値は次のとおりです。 パラメータ 変更前 変更後 max-instances 100(暗黙のデフォルト) 1000 min-instances 0(暗黙のデフォルト) 1 concurrency 1(暗黙のデフォルト) 10 cpu デフォルト 1 memory 512MB 1Gi ここでの本質は、値を大きくしたこと以上に、必要なパラメータをすべて明示したことにあります。今回の引き金は、暗黙のデフォルト値に依存していたことでした。そこで max-instances をはじめとするスケール関連のパラメータをデプロイ定義に明示し、世代やデフォルトの変化に左右されない状態にしました。あわせて、 min-instances を 1 にしてコールドスタートを避け、concurrency を 10 に引き上げ、それに見合うよう CPU とメモリも増強しています。なお max-instances は、書き込み先である Spanner への負荷も考えて、一度に上げきらず段階的に引き上げました。 concurrency を 1 から引き上げることができたのは、ハンドラ内の共有状態を見直し、複数リクエストを同時に処理しても安全だと確認できたためです。Spanner クライアントは内部にコネクションプールを持ち並行アクセスに耐えられます。また、その初期化は sync.Once によって一度だけ行われます。こうした前提を確かめたうえで同時処理数を増やし、必要なインスタンス数そのものを抑えました。 6. Spanner autoscaler の監視軸を Total CPU へ広げる Cloud Run の処理能力を上げると、今度は書き込み先である Spanner がボトルネックになります。Spanner には以前から autoscaler を導入していましたが、障害のさなか、CPU が高負荷であるにもかかわらず PU(Processing Unit。Spanner の計算容量の単位)はまったくスケールアップしていませんでした。autoscaler があるのにスケールしない、という一見不可解な状況でした。 原因は、autoscaler が見ていた指標にありました。Spanner の CPU 使用率には High / Medium / Low の優先度があり、その合算が Total CPU 使用率です。当時の autoscaler は High priority CPU 使用率だけを見ており、障害時は Total CPU が 100% に張り付く一方で、High priority 単体では閾値(60%)の超過が続かず、起動条件を満たしませんでした。この死角は月末ピークに限りません。たとえばリアルタイム性を求めない大量データの削除ジョブは、意図的に Low priority で実行するため、Total は高いのに High は低いという同じ状態を容易に作り出します。優先度別の CPU だけを見る監視は、こうしたケースを構造的に取りこぼすのです。 しかし、これは autoscaler の設定変更では解決できませんでした。当時、OSSである mercari/spanner-autoscaler には、Total CPU でスケールする機能そのものが存在しなかったからです。そこで、OSS をメンテナンスしている SREチームに相談し、High priority と Total を OR 条件で評価する dual CPU scaling mode を実装してもらい、 v0.8.0 として取り込みました。設定した閾値は次のとおりです。 パラメータ 変更前 変更後 Total CPU 閾値 (監視なし) 70% High priority CPU 閾値 60% 55% Total CPU 閾値は新設で、これが今回の障害への直接の対策です。値は Google Cloud の throughput 最適化推奨(〜70%)に合わせました。High priority CPU 閾値は、より早くスケールアップを起動するために 60% から 55% へ下げています。下げすぎると平常時のコストが増えるため、より低い 50% 等は避け、Google Cloud の推奨(regional で 65% 以下)に収まる 55% を選びました。これにより autoscaler は High priority(55%) と Total(70%) のいずれかが閾値を超えればスケールアップするようになり、「Total は逼迫しているのに High が閾値未満だからスケールしない」という障害時の挙動は原理的に起こらなくなりました。 また、スケール条件に加えて、運用面でも2つの調整を行いました。1つは、早朝5時から7時のスケールダウンを禁止することです。月末ピークの立ち上がりで、せっかく確保した PU が削られてしまうのを防ぎます。もう1つは、スケジュールによる事前の PU 確保です。月末早朝や大規模なリコンサイルが走る月など、負荷が読める期間にはあらかじめ PU を確保しておき、リアクティブなスケールアップだけに頼らない構えにしました。 7. 上限への接近を検知する監視を整える 今回の障害には、予兆を早期に捉える機会も、発生を即座に検知する機会も逃してしまったという課題があります。スケール上限に達してもそれを知らせるアラートがなく、水面下で限界に近づいていた兆候も見逃していました。そこでDatadog に2つの監視を追加しました。 1つは、Cloud Run のインスタンス数が上限の 40% / 60% に達した時点で警告・重大として通知する監視です。これにより、メッセージのドロップが始まる前に、上限への接近そのものを検知できます。もう1つは、Pub/Sub の未配信メッセージ数が閾値を超えたら通知する監視で、滞留という現象を直接とらえます。後者は誤検知を避けるため、当初は保守的な閾値から始め、定常状態の理解が進むにつれて段階的に下げていく方針にしています。 設定値を最適化するだけでは不十分です。これ自体は当たり前かもしれませんが、どれだけ良い値を入れても、その値への接近を検知できなければ、次の同じ障害は防げません。キャパシティの設計と、その接近を知る監視をセットで整える。この当たり前を当たり前に徹底することこそが大切だと、今回あらためて実感しました。特に、システムをゼロから作った当人ではなく、引き継いで運用していることのほうが多い現場では、こうした設定や前提を定期的に振り返り、整え直す文化を持つことが欠かせません。 8. まとめ 今回の障害は、1つのデプロイフラグを明示しなかったことから始まりました。しかし振り返れば、本当の教訓はもっと一般的なところにあります。 第一に、マネージドサービスの世代移行では、自分が変更していないデフォルト値こそが、負荷時に問題として表面化します。スケール上限・並行数・タイムアウトのような、平常時には効かないパラメータは、移行のたびに明示しておくべきでした。第二に、障害の原因は単一とは限りません。「直近の月は同じ条件で起きなかった」という事実がトラフィックの連続性という決定的な要因を浮かび上がらせたように、トリガー・増幅・被害化のそれぞれに目を向けることが再発防止には欠かせません。そして第三に、設定の最適化と、その状態に気づくための監視は、つねに一体で考える必要があります。 マネージドサービスの便利なデフォルト値は、平常時には何も語りません。だからこそ、ピーク時に初めて顔を出すその振る舞いを、移行のたびに確かめておく価値があります。この記事が、みなさんが運用するサービスの設定を今一度見直す、ひとつのきっかけになれば幸いです。 次の記事は um(うめ)さんです。引き続きお楽しみください。
こんにちは、メルペイiOSエンジニアのkubomiです。 この記事は Merpay & Mercoin Tech Openness Month 2026 の 10日目の記事です。 生成AIによって、エンジニアが短時間でプロトタイプをつくれる場面はかなり増えました。最近、小規模なプロジェクトで「初回ミーティングの前に、動くものをつくり切ってしまう」という進め方を試したところ、意思決定のスピードが劇的に変わりました。私はこのやり方を "Build First, Discuss Later(まずつくる、議論は後)”と呼んでいます。この記事では、その具体的な進め方と、実践を通じて私自身に起きたマインドセットの変化を紹介します。 よくある開発フローと、その課題 私たちの現場では、開発に取りかかる前に、まず関係者の認識をそろえておくのが一般的です。具体的には、最初にプロダクトマネージャー(PdM)が大まかな仕様を用意し、それをもとにキックオフミーティングを開いて詳細を議論します。議論を経てPdMが仕様を固め、エンジニアはその仕様をもとに見積もりを出して実装に入ります。 ただ、議論して仕様を固めたつもりでも、いざつくり始めると「あれ、ここの挙動どうするんだっけ?」という疑問が次々と出てくることがあります。そのたびにPdMへ確認したり、追加のミーティングを開いたりすると、少しずつコミュニケーションの往復が増えていきます。 "Build First, Discuss Later" という提案 そこで私が実践したのが、プロセスの順番をあえて逆転させる "Build First, Discuss Later" です。仕様が固まる前に、まず動くプロトタイプをつくってしまうという発想で、ミーティングはその動くものを土台に議論を進めます。 従来は「議論して仕様を固めてからつくる」流れでしたが、これを「先につくり、その動くものを見ながら議論する」へと入れ替えます。実際に触れる画面があると、抽象的な仕様書をめぐる議論よりもはるかに早く、関係者の認識がそろっていきます。 ただし、何にでもこの進め方を使うわけではありません。何日もかかるような大きな実装でこれをやると、方針が変わったときの手戻りが大きすぎます。私の場合は、数時間から1日以内でつくれるくらいの小規模な施策に限定しています。そのくらいの規模なら、悩んで待つより、とりあえずつくってしまったほうが圧倒的に速い、という実感があります。 ミーティングにプロトタイプを持ち込む3つのステップ 私は "Build First, Discuss Later" を、ミーティングの前・中・後という3つの場面に分けて実践しています。ここでは、アプリの画面にバナーを追加した事例を例に、それぞれの場面で意識していることを順に紹介します。 ミーティング前:自分のベスト案でつくり切る ミーティング前は、手に入る計画書や仕様書をAIと一緒に読み込み、「なぜつくるのか(Why)」「何をつくるのか(What)」を自分なりに解釈します。この段階で最も大事なのは、完璧な実装をつくることではなく、どこが曖昧なのかを目に見える形にすることだと考えています。実際、詳細が決まっていないことがほとんどですが、曖昧な点にぶつかっても立ち止まりません。PdMに質問する代わりに、いったん自分が考えるベストな案でつくり切り、迷ったポイントはミーティングのアジェンダに整理しておきます。 たとえば、バナー追加の事例では、リリースに必要な最小限の機能に絞って早くリリースするか、将来使い回せる再利用性を優先するか迷いながらも、まず最小限の機能で動くプロトタイプをつくりました。UIデザインがまだない場合も、既存の画面部品を組み合わせた仮の見た目で形にしました。 ミーティング中:動くものを見ながら論点を解消する ミーティング中は、その動くプロトタイプを見せながら議論し、可能な限りその場で論点を解消します。意見が分かれそうな箇所には、あらかじめA案とB案を用意し、「私はこういう理由でA案を推します」と推奨案まで添えておきます。バナーの例では、期日を踏まえて、リリースに必要な機能に絞った設計プランと、将来の再利用まで見据えた設計プランを提示し、PdMはその場で前者のプランに合意できました。細かな仕様も、プロトタイプを見ながらサクサクと決まっていきました。判断の材料がそろっているため、議論は驚くほど早く前に進みます。 ミーティング後:決まった内容をすぐ反映する ミーティング後は、決まった内容を仕様書に反映し、実装を微修正したうえで品質保証(QA)のテストに回します。大きなつくり直しが起きにくく、初回ミーティングの直後にはリリースが見えている、という状態になりました。バナーの例では、私がつくった仮の見た目をデザイナーが本番デザインへブラッシュアップし、実装側はそれを反映する微修正で済みました。 "Build First, Discuss Later" で起きた3つの変化 この進め方を試してみると、ミーティングの進み方やPdMとのやり取りがかなり変わりました。特に大きかった変化は、次の3つです。 1つ目は、ミーティングがほぼ1回で完結するようになったことです。うまくいけば、初回ミーティングが終わった時点で仕様も実装もほぼ固まっており、開発見積もりすら不要になることもあります。その後の往復も大きく減りました。 2つ目は、議論が速く、かつ正確になったことです。実際に動くものを見せながら「この画面の挙動はこれでよいですか」と確認できるため、言葉だけのやり取りで生じがちな認識のズレが起きにくくなりました。 3つ目は、PdMの負担が軽くなったことです。エンジニアが具体的な仕様の案まで持っていくので、PdMは方針を確認するだけで済みます。特にPdMが複数プロジェクトを兼務しているような状況では、その確認コストを減らせるだけでも大きな価値があります。 「待つエンジニア」から「提案するエンジニア」へ こうした変化は、単に開発プロセスやコミュニケーションを効率化しただけでなく、私自身のエンジニアとしてのマインドセットにも影響を与えました。 以前の私は、決まった要件を正しく実装することがエンジニアの主な役割だと思っていました。けれど、PdMが持つWhyと大まかなWhatを起点に、まず動くものをつくってみると、「これは要らないかもしれない」「こっちの方がお客さまに価値を届けられるのでは」といった議論を、自分から持ち込めるようになりました。 いちばん大きかったのは、「私はこれがいいと思う」というアイデアを持ってミーティングに参加できるようになったことです。ただ仕様を待つのではなく、要件定義の段階から意見を出し、仕様を決めていく側に少しずつ入っていけるようになりました。 そうやって関わっていると、「この機能は今いちばん自分が詳しい」というオーナーシップも自然と生まれてきます。自分の提案が仕様に反映され、動くものを通じてプロダクトの方向性が決まっていく。その過程に関われるようになって、プロダクトづくりが前より一層楽しくなりました。 生成AIによって「まずつくってみる」ハードルが下がったことで、エンジニアが上流の議論に入りやすくなったと感じています。プロトタイプをつくってミーティングに持ち込むことは、単に開発を速くするだけではなく、エンジニアがより主体的にプロダクトづくりに関わるためのきっかけにもなるのだと思います。 まとめ "Build First, Discuss Later" は、先に動くプロトタイプをつくり、それを見ながら議論することで、意思決定を速くする進め方です。みなさまも、「仕様待ちで開発が始められない」「仕様が曖昧で手戻りが多い」と感じたら、自分なりのプロトタイプを会議に持ち込んでみてください。会話が前に進むだけでなく、プロダクトづくりの楽しさも少し違って見えてくると思います。 次の記事は mewutoさんです。引き続きお楽しみください。
こんにちは。メルペイでソフトウェアエンジニアをしている @sapuri です。この記事は Merpay & Mercoin Tech Openness Month 2026 の 9日目の記事です。 はじめに 本記事は、2026年4月27日の Background Job Talk 〜 Temporal 活用と独自実装の舞台裏編〜 で発表した「内製ワークフローエンジンの設計とメルカリでの活用事例」を記事化したものです。 マイクロサービスアーキテクチャのような分散システムでは、複数のサービスにまたがる処理のデータ整合性をどう保つか、いわゆる分散トランザクションの扱いが大きな課題となります。 メルカリでは、この課題を Saga パターンによる結果整合性で解決するために、自社でワークフローエンジンを開発して運用しています。 このワークフローエンジンは、もともとメルコインの決済基盤における分散トランザクション管理のために開発したものです。メルペイの Payment Service で得た知見も取り入れながら設計し、現在はメルカリグループ内の複数のユースケースで利用が広がっています。 この記事では、内製に至った背景とワークフローエンジンの具体的な設計、社内での活用事例について紹介します。 分散トランザクション管理の課題と Saga パターン メルカリでは主にマイクロサービスアーキテクチャを採用しています。 そのため、お客さまがアプリで1つの操作をすると、そのリクエストは基本的に複数のサービスをまたいで処理されます。 例えば、メルカリアプリからビットコインを購入するときの決済リクエストでは、取引データの作成、メルコインの日本円残高の減算、メルペイのポイントの減算、ビットコイン残高の加算、取引データの更新といった複数の処理が関わります。 この決済リクエストは、1つのトランザクションとして扱う必要があります。つまり、一連の処理をすべて成功させるか、すべて失敗させるかのどちらかに寄せる必要があります。 しかし、各サービスがそれぞれデータベースを持っているため、単純にロールバックすることはできません。この点を考慮せずに実装すると、エラーのタイミングによってデータの不整合が発生します。 例えば、このような不整合が起こりえます。 決済が失敗したのにメルコインの日本円残高が減っている 残高は減ったがビットコイン残高が加算されない ビットコインと交換できているのに取引が完了扱いになっていない また、Two-Phase Commit のような分散トランザクションでは長期間リソースをロックするため、サービスの可用性が下がる可能性があります。 そのため、メルカリでは結果整合性のアプローチで、このような分散トランザクションを解決しています。 Saga この結果整合性を実現するためのアーキテクチャの1つとして、Saga というパターンがあります。 Saga は、トランザクションを複数の小さなトランザクションに分割して順次実行することで長時間のロックを不要にします。途中でリトライ不可能なエラーが出た場合は、成功済みの処理に対する補償トランザクションを逆順で実行します。 先ほどの暗号資産購入の例で、途中のビットコイン残高を増やす処理でリトライできないエラーが発生した場合を考えます。 この場合、この時点までに成功した処理を取り消す補償トランザクションを逆順に実行します。すでにメルコインの残高とメルペイのポイントが減らされているので、まずポイントを戻し、その次にメルコイン残高を戻し、最後に取引データを失敗として更新します。 このように実装することで、途中のどこで失敗しても結果整合性を保って処理を完了させることができます。 このあたりの話は以前の記事でも紹介しているので、興味のある方はそちらもぜひご覧ください。 メルコイン決済基盤における分散トランザクション管理 | メルカリエンジニアリング ワークフローエンジンの検討 実装の方針が決まったので、実際に Saga パターンを実装するためにワークフローエンジンの導入を検討しました。 主に検討したツールは、 GCP Workflows 、 Cadence 、 Temporal です。メルカリでは主に GCP を使ってサービスを構築しているため、まず GCP Workflows を検討しました。ただ、各処理を HTTP のエンドポイントとして実装する必要があり、ユニットテストがやりにくいという懸念がありました。また、YAML ではなく Go のコードでワークフローを記述したいという要望もありました。 Cadence と Temporal も検討しましたが、メルカリでは Cloud Spanner をメインに使っているため、Spanner に対応していなかったことから採用できませんでした。また、Temporal はシステムの規模が大きく、仕組みも比較的複雑なため、運用面にも不安がありました。 このように、既存ツールでは要件を満たせなかったため、自社でワークフローエンジンを開発することにしました。 開発時には、Cadence / Temporal のインターフェースの良さを取り入れつつ、メルペイの Payment Service ですでに実績があった「DB への実行状態の永続化 x インメモリキュー x Worker での実行管理」のアーキテクチャを再利用する方針にしました。また、Go 専用で必要な機能のみに絞ることで、数人の兼務メンテナーでも運用できる規模にしています。 ワークフローエンジンの設計 アーキテクチャ このワークフローエンジンは、アプリケーションサーバーと同じ Pod でデプロイされることを想定しています。 Go runtime で動作し、利用者は SDK として扱います。 アプリケーションは Manager というインターフェースを使ってワークフローエンジンを操作します。主に Register と Execute という2種類のインターフェースを使います。 アプリケーションはワークフローを普通の Go の関数として実装するので、その関数の内容を事前にワークフローエンジンに登録する必要があります。 manager.RegisterWorkflow() が呼び出されると、Manager は Registry というインメモリの領域に関数を格納します。 manager.Workflow().Execute() は、実際にワークフローを実行するインターフェースです。 呼び出されると、Manager は Engine Server という gRPC サーバーに対して Workflow や Activity を作成するリクエストを送ります。 Engine Server は関数名や引数、実行状態を DB に保存し、インメモリキューである Channel に WorkflowStarted イベントを publish します。 その後、Worker という goroutine が WorkflowStarted イベントを subscribe し、Registry から実行する関数を取得して、Go のリフレクションを使って実行します。 実行が完了すると Worker は Engine Server に完了を報告し、Engine Server は結果を保存して WorkflowCompleted イベントを publish します。 その後、Worker が WorkflowCompleted イベントを subscribe し、アプリケーションに関数の実行結果を返却します。 もしその結果がエラーだった場合は、後述する ErrorMarshaler というインターフェースで、その Workflow を完了させるかどうかを判定します。 ここまで説明したコンポーネントの役割を整理します。 Manager : SDK のエントリーポイント。アプリケーションは Workflow() 、 Activity() 、 RegisterWorkflows() などを呼び出します。 Engine Server : Create、Complete、List などの gRPC API を提供するサーバー。DB に Workflow や Activity の I/O と状態を保存します。 Channel : Workflow や Activity の状態遷移イベントのハブとなるインメモリキュー。 Workers : Workflow や Activity を実行する goroutine 群。Channel から状態遷移イベントを購読し、イベントの種別に応じた処理を実行します。 Registry : Register された関数をインメモリで保持します。 Recovery Worker : Engine Server に対して定期的に未完了の Workflow と Activity を List してリトライします。 コードサンプル アプリケーション側の実装イメージは次のようになります。 func (s *Service) createExchangeWorkflow(ctx context.Context, params *CreateExchangeParams) (*CreateExchangeResult, error) { saga := workflow.NewSaga(s.wm) if err := s.wm.Activity(s.authorizeBalance, params.Balance).ExecuteWait(ctx); err != nil { return nil, err } saga.AddCompensation(s.cancelBalance, params.Balance) if err := s.wm.Activity(s.authorizePoint, params.Point).ExecuteWait(ctx); err != nil { if !isCompletableError(err) { return nil, err } if cerr := saga.Execute(ctx, func(e execution.Execution) error { return e.Wait(ctx) }); cerr != nil { return nil, fmt.Errorf("failed to execute compensation activities: %w, orig_err: %v", cerr, err) } return nil, err } return &CreateExchangeResult{}, nil } まず、 createExchangeWorkflow という関数が定義されています。 この関数は、残高を確保する authorizeBalance という Activity と、ポイントを確保する authorizePoint という Activity を順に実行して結果を返す処理です。 特徴的なのは、それぞれの Activity を実行した直後に、この SDK が提供する Saga の AddCompensation インターフェースで補償トランザクションを登録している点です。 これにより、 authorizeBalance Activity が成功した後に authorizePoint Activity が失敗した場合は、 authorizeBalance を取り消す処理である cancelBalance という関数が補償トランザクションとして実行されます。 エラーハンドリング このワークフローエンジンでは、3種類のエラーを定義しています。 Completable Error : Workflow や Activity を失敗として完了させてよい、想定されたエラーです。例として、残高不足や利用制限があります。 Retryable Error: リトライ対象のエラーです。 Incompletable Error: Workflow を完了させずに停止し、Recovery Worker が後でリトライするエラーです。 Completable Error は、明示的に完了できるエラーだけを完了扱いにするための仕組みです。 クライアント側で ErrorMarshaler というインターフェースを実装したエラーとして定義される 該当しないエラーはすべて未完了として実行を停止し、Recovery Worker によってリトライされる 明示的に Completable Error を返さない限り Workflow は完了しない アプリケーションが意図していない異常な状態で Workflow が完了しない設計になる type ErrorMarshaler interface { MarshalCompletableError(error) ([]byte, error) UnmarshalCompletableError(marshaledErr []byte) error } 具体例として、ドメインのカスタムエラー型に Completable() というメソッドを定義し、それを使って ErrorMarshaler を実装します。 このようなカスタムエラー型を作っておくことで、ビジネスロジックで特定のエラーコードを含むエラーを返すと、ワークフローエンジンで完了可能なエラーとして処理されます。 type Error struct { code ErrorCode msg string } func (e *Error) Error() string { return e.msg } func (e *Error) Completable() bool { return e.code == ErrCodeCompletable } type workflowError struct { Code ErrorCode Msg string } type workflowErrorMarshaler struct{} func (workflowErrorMarshaler) MarshalCompletableError(err error) ([]byte, error) { var aerr *Error if !errors.As(err, &aerr) { return nil, err } if !aerr.Completable() { return nil, err } return json.Marshal(&workflowError{ Code: aerr.code, Msg: aerr.msg, }) } func (workflowErrorMarshaler) UnmarshalCompletableError(data []byte) error { var werr workflowError if err := json.Unmarshal(data, &werr); err != nil { return err } return &Error{ code: werr.Code, msg: werr.Msg, } } 活用事例 ここまで、独自に開発したワークフローエンジンの設計について紹介しました。 ここからは、決済以外のユースケースも含めて、社内での活用事例を3つ紹介します。 1. メルカリモバイル: 同期レスポンスと非同期処理の分離 例えば、メルカリモバイルの回線開通フローでは、Workflow と Child Workflow というサブのワークフローを定義し、同期レスポンスと非同期処理を分離しています。 具体的には、Workflow と Child Workflow で役割を分けています。Workflow は回線開通リクエストを DB に保存し、Child Workflow を fire-and-forget で起動してレスポンスを返します。 Child Workflow は、非同期で Pub/Sub イベントを発行します。失敗した場合は、Recovery Worker が Child Workflow を復旧します。 これにより、クライアントに即座にレスポンスを返しつつ、Pub/Sub 発行のような後続処理がバックグラウンドで実行されることを保証できます。 2. メルカリ グローバル EC 基盤: Saga によるチェックアウト管理 メルカリ グローバル EC 基盤では、Spanner ではなく PostgreSQL を採用しています。 ここでは、購買代行パートナーを経由して海外のお客さまが日本のメルカリの商品を購入する処理を例にします。 例えばチェックアウト確定フローでは、クーポン消費、注文作成、購買代行パートナーへの注文連携、注文確定通知送信の順に処理が進みます。 このチェックアウトの処理で、冒頭で紹介した暗号資産購入のユースケースと同様に Saga パターンを使ってトランザクションを管理しています。 途中で失敗した場合には、Saga による補償トランザクションでキャンセルします。 内製のワークフローエンジンを採用した理由には、社内にすでにある類似実装や運用基盤を活用したかったことがあります。また、メンテナーが社内にいるため直接サポートを受けられることや、必要な機能を柔軟に追加できて最適化しやすいことも大きな理由でした。 3. eKYC: Signal を使った long-running workflow eKYC によるお客さまの本人確認フローは、Workflow の中で数時間から数日の審査待ちが発生するという、いわゆる long-running workflow になっています。 これを実現するために、Temporal でも提供されている Signal という機能を実装しました。 Signal を使うことで、Workflow を中断し、外部から Workflow に情報を送って再開させるユースケースを実現できます。 長期フローを細切れのジョブとして分割せず、書類検証、審査待ち、承認または拒否という一連の流れを1本の Workflow として表現できます。 Signal を使ったアプリケーション側の実装イメージは次のようになります。 type ApprovalSignalParams struct { Approved bool RejectReason string } func (s *Service) approvalWorkflow(ctx context.Context, req *ApprovalRequest) (*ApprovalResult, error) { var result *ApprovalResult if err := s.wm.Activity(s.verifyDocument, req).ExecuteGet(ctx, &result); err != nil { return nil, err } var params ApprovalSignalParams if err := s.wm.Signal("approval").Receive(ctx, &params); err != nil { return nil, err } if !params.Approved { return nil, newCompletableError(params.RejectReason) } return result, nil } func (s *Service) handleApproval(ctx context.Context, workflowIdempotencyKey string, params ApprovalSignalParams) error { return s.wm.Signal("approval").Send(ctx, workflowIdempotencyKey, params) } approvalWorkflow という関数は、 verifyDocument という Activity を実行した後に、 approval という signal を待機します。 審査が終わり、外部から approval signal をこの Workflow に送信すると、 approvalWorkflow が途中から再開します。 まとめ 分散システムにおけるデータ整合性の課題に対して、メルカリでは Saga パターンによる結果整合性を採用し、それを支える仕組みとしてワークフローエンジンを内製しています。 この記事では、その背景と設計、社内での活用事例について紹介しました。 なお、この SDK を使った開発を支援するために、専用の静的解析ツールも開発して運用しています。このあたりの運用面についても発表で触れているので、興味のある方は Speaker Deck のスライドもぜひご覧ください。 次の記事は kubomiさんの「Build First, Discuss Later|初回ミーティングに動くプロトタイプを持ち込んだら、意思決定が爆速になった」です。引き続きお楽しみください。
こんにちは。今年4月に入社したメルペイ Loyalty & Santa(Growth Platform)チームでBackend Engineerをしている@mikupoです。この記事は「 Merpay&Mercoin Tech Openness Month 2026 」の8日目の記事です! はじめに 私が所属しているSantaチームは、メルカリ・メルペイにおけるポイント還元やキャンペーンの基盤となるシステムを開発・運用しています。Santaの処理はすべて非同期で、Pub/SubのPull型 subscriptionを中心に構成されています。 Santaの開発で課題になっていたのが、QAプロセスです。検証に使える開発環境(Dev環境)はチームに1つしかなく、複数の開発(Pull Request 以降、 PR) が重なるとQAを並列に進められませんでした。実装は終わっているのに、検証環境が空くのを待つ、そんな状況が発生していました。 PRごとに独立した環境を用意できれば、この待ち時間はなくせます。ただ、Santaでそれを実現するのは簡単ではありませんでした。Pull型のsubscriptionでは、複数のconsumerが同じsubscriptionに接続している場合、どのconsumerがどのmessageを受け取るかをpublisher側から指定できません。そのため、「このイベントはこのPR環境へ」と狙って届けることができません。 社内にはすでに、こうした課題に使えそうな仕組みもいくつかありました。ただ、それらを Santa に取り入れるには、本番の非同期処理の作りに大きく手を入れる必要がありました。今回達成したいのは QA の改善であり、そのために本番の非同期処理のかたちを大きく作り変えるのは避けたいと考えました。 本記事では、この制約のなかで「Pull型を保ったまま、PRごとに環境を分ける」 をどう実現したのかを紹介します。 Santaについてくわしく知りたい方は、「 メルペイのキャンペーンを支えるサンタの秘密 」をご確認ください。 PR単位の検証環境が必要になった背景 従来、Santaチームの検証に使えるDev環境は1つしかありませんでした。複数のPRを同じDev環境へ同時に反映すると、不具合が起きたときに原因となった変更を切り分けづらくなります。変更同士がconflictする場合は、そもそも並行してQAを進められませんでした。 このボトルネックを減らすための選択肢として、Santaでは Pull Request Replication Controller (PRRC)に注目しました。PRRCとは、GitHubのPRを起点としてDev環境とは別に、PRごとの検証環境(PRRC環境)を作成するためのKubernetes custom controllerを使った仕組みです。 PRRCによってPRごとの検証環境を作る道筋は見えました。次に検討したのが、社内で使われている Pub/Sub gRPC Pusher です。Pub/Sub messageをgRPC requestとして扱えれば、既存の Dynamic Service Routing と組み合わせてPRRC環境へルーティングできるように見えました しかし、SantaのPub/Sub処理はPull型subscriptionを前提に作られています。今回解決したかったのはQAプロセスのボトルネックであり、本番の非同期処理の仕組みを大きく変えることではありませんでした。一方で、gRPC Pusherをそのまま使うには、既存のPub/Sub handlerをgRPC endpointとして受けられる形に変える必要があります。そのため、QA環境の改善として取り組むには、本番環境への影響や変更範囲が大きくなりすぎると判断しました。 そのため、私たちは既存のPull型Pub/Subを保ったままPR単位の検証環境を実現するために、Santa側で満たすべき要件を整理しました。 Pull型Pub/Subを保ったままPR単位の検証環境を作るための要件 既存のgRPC Pusherをそのまま使うのではなく、SantaのPull型 subscriptionを保ったままPR単位の検証環境を実現するには、まず満たすべき要件を整理する必要がありました。PRRC環境を実際の検証に使える状態にするには、messageを意図した環境で受け取れることと、PRRC向けの文脈を後続処理へ引き継げることが重要でした。 ここでは、この2つの要件を順に説明します。 要件1:Dev環境とPRRC環境でmessageを分けて受け取れること 1つ目の要件は、Dev環境で確認したいmessageと、特定のPRRC環境で確認したいmessageを分けて受け取れることです。PRごとの検証環境を作れたとしても、それぞれの環境で確認したいmessageが混ざってしまうと、どのPRの変更による挙動なのかを切り分けづらくなります。 messageが混ざる原因は、Pull型subscriptionの受け取り先をpublisher側から指定できないことにあります。図1のように、PRRC環境のconsumerをDev環境と同じsubscriptionにつなぐと、複数のconsumerが同じsubscriptionからmessageを受け取る構成になります。そのため、Dev環境で確認したいmessageをPRRC環境が受け取ったり、PRRC環境で確認したいmessageをDev環境が受け取ったりする可能性があります。 このため、PR単位の検証環境として使うには、messageを環境ごとに分けて受け取れる仕組みが必要になります。 要件2:PRRC環境向けのルーティング情報を後続処理へ引き継げること 2つ目の要件は、messageがどのPRRC環境向けなのかを、後続の処理にも引き継げることです。Santaの処理はPub/Sub messageを受け取って終わりではなく、処理の途中で他のmicroserviceをgRPCで呼び出したり、別のPub/Sub messageをpublishしたりします。 そのため、SantaのDev環境とPRRC環境でmessageを分けて受け取れるだけでは不十分です。結合テストでは、Santaから呼び出すmicroservice側にもPRRC環境が用意されている場合があります。その場合、Santaからの呼び出しも通常のDev環境ではなく、対応するmicroserviceのPRRC環境へルーティングできる必要があります。 図2のように、このルーティングを実現するには、Santaが受け取ったmessageに付与されたルーティング情報を、後続のgRPC呼び出しやPub/Sub publishにも引き継ぐ必要があります。つまり、message自体にルーティング情報を持たせ、Santa内の処理でもその情報を落とさない仕組みが必要になります。 実現方法の検討 ここまで整理した2つの要件を満たすには、Pub/Sub messageにルーティング情報を持たせたうえで、その情報をどの段階で使ってmessageを振り分けるかを決める必要がありました。大きく分けると、consumerがmessageを受け取ったあとに判断する方法、Topic自体を分ける方法、subscriptionのfilterで受け取るmessageを分ける方法があります。 私たちは、それぞれの方法について、Pull型subscriptionを維持できるか、対象外のmessageを余計にpullしないか、PRRCごとの運用負荷が大きくなりすぎないか、という観点で比較しました。 最初に検討したのは、consumer側でmessage attributeを見て、対象外のmessageを処理しない方法でした。この方法は実装範囲が小さく見えますが、対象外のmessageも一度pullしてしまいます。対象外のmessageをackすると本来処理すべき環境に届かず、nackするとredeliveryが繰り返される可能性があります。 TopicをDev用とPRRC用に分ける方法も検討しました。この方法では、あらかじめPRRC用のTopicとsubscriptionのペアを用意しておき、PRごとにどのペアを使うかを割り当てます。Topic単位で分離できるため構成は直感的ですが、PRごとの割り当てを手動で管理する必要があります。そのため、割り当て忘れや重複割り当てが起きやすく、PRRC環境が増えるほど運用負荷が高くなります。 これらに対して、subscription filterを使う方法では、consumerがmessageをpullする前に、subscription側で受け取るmessageを分けられます。さらに、振り分けに使うmessage attributeをそのままルーティング情報として扱えるため、後続のPub/Sub publishやgRPC呼び出しにも同じ情報を引き継げます。つまり、要件1の「messageを環境ごとに分けて受け取ること」と、要件2の「ルーティング情報を後続処理へ引き継ぐこと」を、同じmessage attributeを軸に実現できます。 最終的な方針 最終的な方針は、Pull型subscriptionを維持したまま、pubsub messageのattributeで配送先を分けることです。各messageのattributeにルーティング情報を付け、subscription側はその値をfilterの条件にします。こうすると、同じtopicに届いたmessageでも、条件に一致したsubscriptionだけがmessageを受け取ります。 図のように、たとえば PR番号1234 を検証する場合、その message の attribute には、PR番号に対応するルーティング情報(PR1234)を付与します。Dev環境のsubscriptionはこのmessageを受け取らず、PR1234用のsubscriptionだけが受け取ります。 さらにこのルーティング情報は、Santaがpublishするmessageにも引き継げます。受け取ったPub/Sub messageのattributeからルーティング情報を読み取り、contextに格納し、後続のpublishや外部microserviceの呼び出しへ渡します。これにより、PRRC の文脈が処理の途中で途切れません。 一方で、外部サービスの subscription にそのまま filterを足すことはできませんでした。Santa は、ポイント還元などのトリガーとなるイベントを、決済をはじめとする他のmicroservice から Pub/Sub で受け取っています。これらの外部 subscriptionは発行側の microservice(=別チーム)の管理範囲にあり、Santa が直接 filterを足すと、別チームが管理するリソースに手を入れることになってしまいます。そこで外部 subscription については proxy sidecar を挟み、Santa 側の proxy topicへ再 publish する構成にしました。Santa の Pod は外部 subscription を直接 pullせず、proxy topic に対して作った Dev / PRRC 用の filtered subscriptionを読みます。 この方針により、Pull型subscriptionを維持したまま、Pub/Sub messageを意図した環境だけに届けられ、しかも変更をSantaの管轄内だけで完結できます。一方で、PRRCごとにsubscriptionを作る必要があるため、その自動作成・削除の設計が新たに必要になりました。 まとめ 本記事では、Pull型Pub/Subを維持したまま、Pub/Sub message attributeとfiltered subscriptionを使ってSantaにPRRC環境を導入した取り組みを紹介しました。 従来は、QA期間が重複する場合には統合環境を用意するか、複雑なプロジェクト同士の場合は直列で進めてQA待ちが発生するしかありませんでした。しかし、実際にこの仕組みを用いることで、直近の大きな2つのプロジェクトではQAを並列化して進めることができました。また、共有設定を変更するテストを直列でしか実施できなかったのが、QAメンバーの人数に合わせて3つ4つとスケールできるようになり、QA期間の短縮にも貢献できました。 自分たちのチームの状況も踏まえながら、よりチームに合った解決策を模索し、それを実現できたのは、チームの皆さんの協力があってこそです。Loyalty & Incentiveチームの皆様、ありがとうございました! 次の記事は sapuriさんの「内製ワークフローエンジンの設計とメルカリでの活用事例」です。引き続きお楽しみください。
こんにちは。メルペイのフロントエンドエンジニアの @mattsuu です。この記事は「 Merpay & Mercoin Tech Openness Month 2026 」の7日目の記事です。 EGP Code は、ランディングページ(LP)を AI と作る社内向けのエディタです。作成背景については AI と作る HTML ベースの LP エディタ EGP Code を内製した理由 という記事で紹介しました。本記事では、その内部で動いている 4 つの仕組みを紹介します。 EGP Code が扱うのは、HTML と状態や動きを担う少数の <egp-*> (独自の Web Components)を混ぜた 1 枚のページです。 <section class="p-6 text-center"> <h1>春のキャンペーン</h1> <p>応募受付中</p> <egp-button>応募する</egp-button> </section> この HTML を AI エージェントとの対話やエディタで編集し、プレビューで確認して公開します。紹介する 4 つの仕組みは、(1) エージェントの再帰ループ、(2) Firestore を介したリアルタイム反映、(3) ブラウザだけで完結するテストランナー、(4) プレビューと HTML を結ぶ対応表です。 1 つの指示がユーザーから届いてプレビューに反映されるまでの流れと、それぞれの仕組みが効く場所は次のとおりです。 仕組み 1: 文脈とツールを束ねるエージェントの再帰ループ 「フォントサイズを 24px にして」「文字を太くして」のように特定の要素への簡単な指示なら、その要素を特定して CSS を更新したり文言を調整したりするだけなので、ほぼ 1 回の操作で終わります。一方で複数の要素にまとめて指示したり、API を使った画面やテストを作ったり、Lint・テストのエラーを直したりする場合は、推論とツール実行を何度か往復することになります。 エージェントは「推論 → ツール実行 → 結果を会話履歴に追加 → 再び推論」という再帰ループで動きます。ここでいうツールとは、AI が必要と判断したときに呼べる関数の定義と説明のことです。HTML を書き換える、ファイルを読む、Lint で検証する、テストを走らせる、といった操作をツールとして用意しておき、モデルがその中から必要なものを選んで呼び、戻り値を見て次の手を考えます。 1 回の入力に対して 4 つの情報をまとめて渡します。システムプロンプトで AI エージェントに役割やコード生成のルールといった前提を与え、それにユーザーの指示と選択要素と現在の HTML、これまでの会話履歴、そして使えるツールの一覧を教えます。このうち現在の HTML・仕様・テストといったページの状態は、種類ごとに XML タグで区切ってまとめます。 会話履歴の持ち方は、開発当初 OpenAI API 側に任せていました。しかし全社的に ZDR(Zero Data Retention) を適用することになり、プロバイダ側に会話を残せなくなったため、いまは履歴をすべて自前で記録し、毎回のリクエストに載せて送るステートレス方式にしています。履歴の肥大化を防ぐため、一定量を超えたら要約させてコンテキストを圧縮しています。 // 1 ラウンドの推論 const stream = await client.responses.stream({ ...args, input: sessionBuffer.getItems(), // 自前で管理している履歴全体を送る previous_response_id: undefined, store: false, // プロバイダ側に会話を保存させない }); ループの工夫をいくつか紹介します。 1 つ目は、ツールの失敗の扱いです。ここでいう失敗とは、 apply_patch の差分が当たらない、Lint がエラーを返す、テストが落ちるなど、ツールが期待どおりに完了しなかった状態を指します。こうした失敗ではループを止めず、エラーの内容をそのまま結果としてエージェントに返すことで自己修正させています。 2 つ目は、 find_skill ツールによる情報の出し分けです。社内 API の使い方などのドキュメントは、最初は ID と一行説明の一覧だけを見せておき、本文は必要になったタイミングで読み込みます。たとえば「商品一覧を表示したい」という指示が来ると、エージェントはまず一覧から関連しそうなものを探し、 find_skill でそのドキュメント本文を取得します。エージェントは取得したドキュメントを読み、正しい引数で API を呼びます。 この推論とツール実行の往復を繰り返し、最後にエージェントが apply_patch で HTML を差分更新すると 1 つの指示が編集として完成します。 ループの途中で方向を変える Real-time steering ここまでは、1 つの指示を最後まで処理してから次を受け取る前提でした。ですが実際には、処理の途中で「やっぱり色は青にして」と方針を変えたり、「ついでにフッターも直して」と指示を足したくなることがあります。完了を待たずに割り込みで指示を足し、走っているループに後から反映する仕組みを用意しています。こうした仕組みは Real-time steering とも呼ばれます。 ユーザーが処理中に指示を足したときの流れは、次のようになります。 エージェントが処理中(画面がローディング中)にユーザーがメッセージを送ると、クライアントはそれを通常の指示ではなく、割り込みメッセージとして送信します。サーバは受け取った割り込みメッセージを、 Firestore の通常の会話履歴とは別のサブコレクションに、いま走っているリクエストの ID を添えて書き込みます。エージェントは、自分が処理しているリクエストの ID に一致する割り込みだけを読み取ります。 // 自分のリクエスト宛の割り込みメッセージだけを読み、読んだら消す const consumeSteeringMessages = async (conversationId, requestId) => { const snapshot = await steeringRef .where('requestId', '==', requestId) // このリクエスト宛だけを対象にする .orderBy('timestamp', 'asc') .get(); const messages = snapshot.docs.map((doc) => doc.data()); await deleteDocs(snapshot.docs); // 読んだら消す return messages; }; ID で絞るので別のリクエスト宛の割り込みを拾うことはなく、読んだら消すので同じ指示が二重に効いたり取りこぼしたりすることもありません。処理のループに差し込むため、割り込みが来たときにエージェントが何をしようとしていたかで、対応が 2 つに分かれます。 1 つ目は、ツールを呼ぼうとしていた場合です。そのツールを実行せず、戻り値の代わりに「ツールは実行していません。処理中に新しい指示が届きました」という内容を返します。 // ツール実行の直前に割り込みを確認する const steering = await consumeSteeringMessages(conversationId, requestId); if (steering.length > 0 && toolCalls.length > 0) { for (const toolCall of toolCalls) { // ツールは実行せず、戻り値の代わりに割り込みを差し込む buffer.pushMessage({ role: 'tool', tool_call_id: toolCall.id, content: '[CONVERSATION_STEERING] 処理中に新しい指示が届きました ...', }); } continue; // 計画を立て直すため、もう一度推論へ } 2 つ目は、ツールを呼ばずに、ユーザーへの返信メッセージを作り終えていた場合です。本来ならこれを見せてループが終わるところですが、割り込みが届いたので止めるべきツールがありません。この返信はまだ画面に出していないので破棄し、直前のユーザーの指示に割り込みメッセージを足して送り直すことで、続きの作業を依頼します。 // ツールがない場合は、画面に出していない返信を捨てて指示を足す if (steering.length > 0 && toolCalls.length === 0) { buffer.pop(); // まだ画面に出していない返信を捨てる const priorUserTurn = buffer.pop(); // 直前のユーザーの指示を取り出す buffer.pushMessage({ role: 'user', content: `${priorUserTurn.content}\\n[CONVERSATION_STEERING] 処理中に新しい指示が届きました ...`, }); continue; } いずれの場合も、すでに実行した副作用を巻き戻すわけではなく、これから実行するはずだったツールを止めたり、まだ画面に出していない返信を捨てたりして、計画を組み直しています。 仕組み 2: Firestore を指示の受け渡し場所にしたリアルタイム反映 仕組み 1 で見たように、エージェントは複数のツールを往復させて指示に応えるため、編集には時間がかかることがあります。処理が終わるまで画面が何も更新されないと、利用者は反映されたかどうか分からないまま待つことになります。 そこで Firestore SDK を利用して、 変更をリアルタイムに受け取れる ようにしています。サーバ側は 1 つの操作が終わるたびにその内容を Firestore へ書き込み、ブラウザ側はそれをサブスクライブして即座に検知・反映します。これで自前で WebSocket を張らずに、編集の途中経過をそのままプレビューへ反映できます。 エージェントが何かを書き換えると、サーバは会話ごとのコレクションに、次のようなドキュメントを 1 件追加します。 { "status": "PENDING", "requests": [ { "action": "setHtmlSchema", "payload": "<body> ...更新後の HTML... </body>", "reason": "ユーザーの依頼を反映" } ] } ブラウザ側の JS は、Firestore の SDK( onSnapshot )でこのコレクションをサブスクライブしており、 PENDING のドキュメントが届くと requests の各操作をエディタの状態へ反映します。たとえば setHtmlSchema なら、エディタが表示している HTML を新しいものに置き換えて、プレビューを再描画します。 ブラウザが requests の操作を反映し終えると、その JS が status を COMPLETED に書き戻します。HTML 差し替えなどの「反映だけ」のアクションは、投げたら終わりで結果を待ちません。一方でテスト実行のように結果が必要なアクションでは、ドキュメントが COMPLETED になるまで一定間隔で読み直して、書き込まれた結果を取り出します。取り出した結果はツールの戻り値としてエージェントに返り、それを見て次のツール呼び出しを決めます。 仕組み 3: ブラウザだけで完結するテストランナー 応募ボタンを押したときの API 呼び出しやその結果に応じた表示の切り替え、リンクによる画面遷移といった LP の動作を手動で確認するのは手間がかかります。そこで EGP Code では、こうした動作をテストで確かめられるようにしています。 テストはブラウザ上のエディタで直接書いたり、AI に書かせたりできます。これらのテストは、サーバや CI ではなく、プレビューと同じブラウザの中で実行します。ただし Jest や Vitest は Node.js 上で動くツールなので、そのままブラウザには読み込めません。そこで test / it / describe / expect を提供する小さなランナーを自作しました。アサーションは単体で使える @vitest/expect 、DOM 操作は Testing Library をそのまま利用しています。 // 自作の test 関数でテストを定義する test('エントリーボタンで API が呼ばれる', async () => { // Testing Library でユーザーのクリックを再現する await userEvent.click(screen.getByText('エントリー')); // @vitest/expect で結果を検証する expect(mockEntry).toHaveBeenCalledWith({ campaign: 'X' }); }); テストが社内 API を呼ぶこともありますが、本番に飛ばすわけにはいきません。そこで iframe 内で window.fetch を差し替え、リクエストはすべてモック関数に通します。モックしていない呼び出しはエラーになるので、本番へ漏れることはありません。 テストの実行は、この iframe を作るエディタのページ(ホスト)と iframe の postMessage のやり取りで進みます。 ホストは iframe を作ってテスト用 HTML を流し込み、iframe 側の初期化( window.fetch の差し替えなど)が済むのを待ってから実行を指示します。先に指示が届くと取りこぼすため、必ず「準備完了」を待つようにします。結果は仕組み 2 のアクションでエージェントへ戻ります。失敗していれば、仕組み 1 で触れた自己修正がここで働き、内容を読んで実装を直してもう一度走らせます。 仕組み 4: プレビューの要素と HTML の位置を結ぶ対応表 ここまではエージェント主導の編集を見てきましたが、人が直接手を入れる場面もあります。Code タブを開くと Monaco エディタで HTML を直接編集でき、プレビューでリアルタイムに確認できます。プレビューの要素をクリックしてエージェントへ指示を出したり、Monaco の対応行へジャンプするには、「プレビューの要素と HTML 上の位置」を対応づける仕組みが必要です。 HTML をパースして各要素へ data-egp-src-id (公開ページには残らない内部 id)を注入してプレビューを描画することで、これを実現しています。 <section data-egp-src-id="src-10"> <h1 data-egp-src-id="src-11">春のキャンペーン</h1> <egp-button data-egp-src-id="src-42">応募する</egp-button> </section> プレビュー上で「応募する」ボタンをクリックすると src-42 が取れます。クリック先が Web Components の内部要素などで id を持たない場合もあるため、 closest で祖先方向に遡って最寄りの data-egp-src-id を探します。 取得した id はエディタが各要素に振った内部 id で、対応表を引くキーになります。対応表はパース時に作る「id → その要素の HTML 上の位置」の Map で、id を引くと文字列オフセットやタグ名が取れます。 // 対応表(id → HTML 上の位置とタグ) Map { 'src-10' => { range: { startOffset: 0, endOffset: 130 }, tagName: 'section' }, 'src-42' => { range: { startOffset: 64, endOffset: 110 }, tagName: 'egp-button' }, // ... } クリックされるのは描画後の DOM 要素で位置を持つのはこの対応表です。要素に振った data-egp-src-id をキーにすることで、両者を突き合わせています。この対応表には 2 つの用途があります。1 つ目が Monaco エディタへのジャンプです。 // 対応表から位置を取得 const range = await mappingApi.findRangeById('src-42'); // オフセットを Monaco の行・列に変換 const pos = model.getPositionAt(range.startOffset); // その行をエディタの中央に表示してカーソルを移動 editor.revealPositionInCenter(pos); 取得した id を元に対応表から文字列オフセットを取り出して Monaco の行・列に変換します。その行をエディタ中央にスクロールしてカーソルを移動することでジャンプさせています。 もう 1 つが AI への指示です。 data-egp-src-id が付いているのはプレビュー用に描画した HTML だけで、エージェントが読み書きする公開用の HTML には付いていないため、id をそのまま渡しても対応する要素は見つかりません。そこで id は対応表を引くキーとしてのみ使い、エージェントには、そこから取り出した要素の HTML・タグ名・行番号をユーザーの指示文と一緒に XML へまとめて渡します。 <annotated_elements> <annotation> <tagName>egp-button</tagName> <snippet><egp-button class="..."></snippet> <lineNumber>10</lineNumber> <instruction>色を赤にして</instruction> </annotation> </annotated_elements> エージェントはこれらを手がかりに HTML の中から対象要素を見つけて指示に対応します。このように指示を XML タグで構造化する書き方は、 Anthropic のプロンプトのベストプラクティスの一つ としても紹介されています。 おわりに 一見シンプルに見える AI エディタですが、実際に体験を作ろうとすると、待ち時間が長い、途中経過が見えない、どこを編集したか分からないといった小さなつまずきが積み重なります。EGP Code では、そのつまずきを 1 つずつ潰すために、ここまで紹介した仕組みを裏側で積み上げてきました。AI を組み込むときほど、モデルの手前にある体験と安全性を設計することが重要だと感じています。本記事が、AI を組み込んだプロダクトづくりの参考になれば幸いです。 次の記事は@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) 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さんです。引き続きお楽しみください。
こんにちは。メルペイ Payment & Customer Platform (PCP) チームでBackend Engineerをしている@imamuです。この記事は「 Merpay&Mercoin Tech Openness Month 2026 」の6日目の記事になります。 はじめに 「事業者請求払い」とは、事業者(加盟店やビジネスパートナー)向けの後払い決済の仕組みです。取引が発生した時点ではプラットフォーム側が代金を立替え、後からまとめて請求・回収します。事業者にとっては都度の支払いが不要になり、取引のたびにキャッシュフローを気にせず利用できるという利点があります。 立替えて後から請求する以上、プラットフォーム側には一つの重要な責務が生まれます。 事業者ごとに「いくらまで立替えてよいか」= 与信上限を安全に管理すること です。これが破綻すると、回収できない債権が積み上がり事業全体のリスクになります。 本記事では、事業者請求払いを実現するために与信ドメインをCredit Serviceとして切り出した際の4つの設計ポイントを紹介します。 背景:従来の請求払いの課題 事業者請求払いは、取引時に代金を立替え、後からまとめて請求・回収する仕組みです。概念的には、次のようなライフサイクルになります。 従来、この請求払いを実現する手段は主に3つありました。 1. 外部のあと払いサービス いくつかのプロダクトは 外部のあと払いサービスを使って与信管理・請求を実現していました が、プロダクト特性に応じたリスク管理やパートナーに応じた柔軟な請求、入金イベントをトリガーにした精算や会計連携のシステム化が難しい側面があり、システム外での運用が避けられませんでした。 2. 外部の請求代行サービス 請求書の発行・送付といった請求業務のみを外部サービスに委ねる方法も取られていました。しかし、与信枠を自社で管理していないため上限を超過しても取引自体は通ってしまうリスクがありました。 3. クレジットカードによる代理決済 一般的な法人カードで利用上限の範囲内で建替え、請求する運用も使われていましたが、取引前に「いまいくらまで使えるか」を自社システムで把握できず、取引が成立した後の決済段階で初めて残高不足エラーが顕在化し、お客さまの体験にも悪影響が出ていました。 目的:なぜ内製の与信管理が必要だったか 前節の3つの手段はいずれも、請求払いそのものは実現できていました。しかし、プロダクトごとに運用や要件が異なる状態では、 事業者ごとの与信をプロダクト横断で一元管理できない という共通の課題が残ります。事業者請求払いは複数のプロダクトでの利用が見込まれるため、プラットフォームとしては次の不変条件を常に満たす必要があります。 プロダクト横断で保有する債権の合計 < 事業者の与信上限 例えば事業者の与信上限が 100 のとき、プロダクトAの利用 60 とプロダクトBの利用 60 が同時に成立して合計 120 になる、といった状態を許してはなりません。プロダクトごとに与信判断が分散していると、この不変条件は容易に破られてしまいます。 そこで私たちは与信管理をプラットフォーム側で提供し、次の3点を満たすことを目的にしました。 プロダクト横断で与信・債権を一元管理すること 不変条件「債権合計 < 与信上限」を常に満たせる 取引前に安全に通すこと いま使える額を判断し、必要に応じて与信枠を確保したうえで取引を開始できる 請求・入金・精算プロセスまでシームレスにつなぐこと 取引の状態変化に追従し、後続の請求・入金・精算へ自然につなげる 設計:4つの設計ポイント 設計は次のようなサービス連携のユースケースを想定して組み立てました。 まずPayment Serviceが各プロダクトから決済リクエストを受け付けます。これはPayment Serviceが決済手段の提供と決済リソースの状態管理を責務としているためです。そして、Credit Serviceに与信枠の利用を依頼し、Credit Serviceが債権管理を担うDebt Serviceに債権を登録します。この債権を集約してInvoice Serviceが請求書を発行します。この連携を以下の4つの観点で設計しました。 1. 与信管理の責務を1サービスに凝集する まず決めたのは「与信ドメインをどのサービスが持つか」です。 すでに存在するPayment Serviceは決済手段の提供と決済リソースの状態管理を責務としています。ここに与信枠の管理や利用可能額の計算といった与信ドメインの知識を持ち込むと、Payment Serviceが本来のスコープを超えて肥大化してしまいます。そこで、与信に関わる以下の責務を全てCredit Serviceに凝集しました。 与信上限の決定 (事業者審査の結果として上限を確定する) 与信枠の作成・更新 (プロダクトごとに任意に切れる枠を管理する) 利用可能額の計算 (いまいくらまで使えるかを算出する) 与信枠の利用 (与信を消費し、債権の登録を依頼する) 審査(枠を決める)と利用(枠を使う)をあえて分離せず一体で持つことにしました。こうすることで、Payment Serviceは「与信枠を利用する」という一つの操作を呼ぶだけでよくなり、利用可能額のチェック・与信の消費・債権登録という一連の処理がサービス内に閉じます。また、横断的な与信上限(目的の不変条件)をアトミックに守るには、与信の利用を1サービスに集約する以外に方法がありません。このように、責務の凝集は明確なドメイン境界と不変条件から導いた設計判断です。 2. 与信と請求を分離する 次に 与信の単位と請求の単位は同じではない という前提を設計に組み込みました。 与信は「どの枠でいくら使えるか」を、事業者が任意に切った枠(CreditLine)の単位で管理したい 請求は「どの宛先にまとめて請求書を出すか」を、請求先(InvoiceAccount)の単位で管理したい この2つを密結合にしてしまうと、「請求の単位を変えたいだけ」で与信側まで作り直すことになります。両者は本来別々の関心事なので、疎結合に保つ必要があります。 実現方法は次のとおりです。Credit Serviceは与信を消費して債権を登録するとき、その債権に 「どの請求先に属するか」「どの与信枠に属するか」という集計のための情報 を持たせてDebt Serviceに渡します。Debt Serviceは、債権を 複数の軸で集計・一覧できる仕組み を備えており、以下のようにそれぞれのサービスが債権の情報を参照することができます。 Credit Serviceは「与信枠の軸」で未返済の債権から利用額を計算する Invoice Serviceは「請求先の軸」で指定期間の債権から請求書を発行する これにより与信は事業者全体で管理しつつ、請求は支店ごとに行うようなユースケースに対応できます。ポイントは 債権の集計軸をDebt Serviceが中心的な概念として扱う ことにあります。Credit Serviceは与信枠の軸だけを、Invoice Serviceは請求先の軸だけを関心に持ち、軸ごとの集計はDebt Serviceに委ねます。結果として、 与信の粒度(CreditLine)と請求の粒度(InvoiceAccount)を独立して設計 [1] できます 。 3. 事業者ごとに複数の与信枠を持てる 与信枠は事業者単位で1つではなく、 1事業者が複数の与信枠(CreditLine)を持てる 構造にしました。店舗ごと・部門ごとといった、事業者で運用したい任意の単位で枠を切れます。これによってプロダクト毎に柔軟に利用制限を行うことができます。 ただし、複数の枠を切っても事業者全体の与信上限(CreditLimit)は超えられません。そこで最終的な利用可能額は、与信枠の利用可能額と事業者全体の利用可能額の小さい方で決まります。 最終的な利用可能額 = min(CreditLine の利用可能額, 事業者 CreditLimit の利用可能額) ここで、似て非なる2つの「上限」を区別しておきます。 CreditLimit CreditLine 意味 絶対に超えて立て替えない上限 クライアントが切れる 実用枠 決め方 与信審査の結果 クライアントが任意設定 クライアントの変更 不可 更新可能 「審査で決まる硬い上限」と「運用で柔軟に切れる枠」を分けることで、安全性と柔軟性を両立しています。 4. 与信の利用はライフサイクルに沿って状態を持つ 与信枠の利用は、一度の操作で完結するものではありません。実際の取引は、与信を押さえてから確定するまでに時間差があり、途中で取り消されることもあります。そこで与信の利用を、複数のフェーズを持つ 与信トランザクション(CreditTransaction) として管理します。 取引開始時点で与信枠を押さえる 確保(Authorize) 、取引確定時点で債権として確定する 確定(Capture) 、確定前に押さえた与信を解放する 取消(Cancel) という3つのフェーズを扱います。これにより、取引の途中で状態が変化しても、与信の消費と解放を一貫したモデルで管理できます。 確保のタイミングを選択可能に(ReservationPolicy) 与信をいつ・どう押さえたいかはプロダクトによって異なります。そこで確保の仕方を ReservationPolicy として選べるようにし、同じ Credit Service を要件に合わせて使い分けられるようにしました。 確保の仕方 動作 仮確保 (PROVISIONAL) 依頼時に仮の債権で与信を押さえ、確定で正式な債権にする/取消で解放する 確定時に確保 (CHECK_THEN_CAPTURE) 依頼時は利用可能額の確認だけ行い、確定時にまとめて確保する 即時確保 (IMMEDIATE) 依頼した時点で確保と確定を同時に行う 確定後の取消にも対応する(Reversal) 運用上は、与信を確保して取引が確定した 後 に取消が発生することがあります。確定後は与信を解放するだけでは済まず、すでに登録された債権をどう扱うかが問題になるため、確定後の取消を Reversal としてサポートしました。 Reversal では、取消したい金額をその時点の債権残額に応じて未返済分と返済済み分に切り分けます。未返済分は債権そのものを取り消し、返済済み分は債権を取り消せないため、返金すべき金額としてレスポンスで返して呼び出し元(Payment Service)が返金できるようにします。これにより、確保→確定→取消・返金という取引のライフサイクル全体に対して一貫した扱いができます。 全体像:プラットフォーム全体で一気通貫に ここまで見てきたCredit Serviceは、単独で成り立つものではありません。プラットフォームには、Payment(決済)・Debt(債権)・Invoice(請求)・Bank(入金)・Balance(残高)・Settlement(精算)といった、それぞれ明確な責務を持つサービスがすでに存在していました。そこにCredit Serviceが新たに加わることで、各サービスが自分の責務に集中したまま連携し、 決済・与信管理・請求・精算がプラットフォーム全体で一気通貫に実現できる ようになりました。 この構成によって、新しいプロダクトがシームレスに事業者請求払いに対応することができます。あるプロダクトで事業者請求払いを利用したくなったら、必要なのは 与信枠(CreditLine)と請求先(InvoiceAccount)を新たに作るだけ です。プラットフォームの各サービスに手を入れることなく、そのプロダクトの取引が、与信照会から決済・請求・返済までのライフサイクルに自然に乗ります。 終わりに 与信管理マイクロサービスができたことによってPayment Platformとして事業者請求払いを実現できるようになりました。 Payment Platformは今後も進化していきますが、「各サービスが明確な責務を持ち、疎結合に連携する」という設計方針そのものは変わりません。この方針のもとに安全かつ拡張可能な決済基盤を今後も実現していきたいと考えています。 次の記事は nanacomさんとmattsuuさんです。引き続きお楽しみください。 [1] この「債権を任意の軸で集計する仕組み」自体も独立した設計テーマであり、別記事で詳しく紹介される予定です。
はじめに こんにちは。メルペイのAccountingチームでBackend Engineerをしている @hokao です。 この記事は、 Merpay & Mercoin Tech Openness Month 2026 の 5 日目の記事です。 会計データに誤りがあった場合、元のデータを残したまま打ち消すための記録を別途追加するのが会計上の一般的な手法です。本稿では、これをシステムとしてどう扱ったかを設計と実装の観点から紹介します。 背景 Accountingチームでは、会計データを扱うシステムを開発しています。メルカリグループ全体で発生するお金の移動を伴う取引を記録・集計するシステムで、会計イベントの保存と経理向けのレポーティングを責務としています。 会計データは、取引の事実を証明する証拠としての役割を持ちます。そのため、一度記録したデータを後から改変・削除することは原則として許されません。誤りがあったとしても元データを残したまま、打ち消すための記録を別途追加することで修正します。 しかし、私たちのシステムにはこの打ち消すための機能が存在していませんでした。誤って登録された会計データが見つかるたびに、その件数・金額などの情報を手作業で特定し、経理に連携して対応してもらう必要がありました。この運用には、対応コストの大きさや作業ミスのリスクといった構造的な課題がありました。 以降では、会計ドメインの前提を整理した上で、この課題を解決するために導入した打ち消し機能の設計と実装を順に説明します。 会計ドメインの前提 会計では、すべての取引を借方と貸方の 2 つに分けて記録する複式簿記という方式が使われています。借方と貸方それぞれに勘定科目・日付・金額を記載したものが「仕訳」で、これが会計データの最小単位になります。 仕訳に誤りがあった場合、元の仕訳の借方と貸方を入れ替えた「逆仕訳」を計上して打ち消します。 簡単な例として、ある仕入取引を次のように記録していたとします。 借方: 仕入 100 円 貸方: 現金 100 円 この記録が誤りだった場合、逆仕訳は次のようになります。 借方: 現金 100 円 貸方: 仕入 100 円 元の仕訳と逆仕訳を合算すると、勘定科目ごとに借方と貸方が打ち消し合い、金額がゼロになります。元データは残したまま、後から追加した記録によって取引を実質的に打ち消す形になります。 ここで説明したのは、逆仕訳がなぜ打ち消しとして成立するのかという会計上の考え方です。実際の会計レポートでは、必ずしも借方と貸方を足し合わせて相殺しているわけではなく、レポートによっては逆仕訳の金額を符号反転させて打ち消しを表現しています。詳しくは後述します。 逆仕訳の設計と実装 スキーマでの逆仕訳の表現 私たちの会計システムでは、上流のマイクロサービスから受け取った取引はまず会計イベントとして記録され、そこから仕訳が作成される構成になっています。それぞれ AccountingEvents と JournalEntries というテーブルで管理されています。 会計イベント ( AccountingEvents ) には、上流のマイクロサービスから会計の入力として受け取った取引が記録され、会計処理の種類や取引の中身を保持します。これに対して仕訳 ( JournalEntries ) は、会計イベントに仕訳ルールを適用して必要な属性が確定した、正式な会計記録です。1 つの会計イベントから、仕訳ルールの適用によって複数の仕訳が作成されることもあります。 逆仕訳の会計イベントには、必ず打ち消し対象となる元の会計イベントが存在します。そのため、 AccountingEvents に元の会計イベントへの参照 ( OriginalTransactionId ) を持たせて関係性を表現します。この参照を持つイベントから作成される仕訳はすべて逆仕訳になります。 また、登録時にはこの参照を辿って、次のようなバリデーションを行います。 元の会計イベントが存在するか 元の会計イベントと整合する勘定科目か 元の会計イベントが既に他の逆仕訳によって打ち消されていないか 会計レポートには JournalEntries から集計するものと、分析用に AccountingEvents から集計するものがあります。どちらの場合でも JOIN なしで逆仕訳の判定ができるよう、 JournalEntries には、その仕訳が逆仕訳かどうかを表すフラグ ( IsReversal ) を持たせています。このフラグは本質的には AccountingEvents の OriginalTransactionId から決まる情報ですが、 JournalEntries でも独立に判定できるよう別途持たせています。 同じ会計イベントに対する逆仕訳の会計イベントが複数存在してはいけないため、上述のアプリケーション側のバリデーションに加えて、DB 側にも一意性制約を設けています。具体的には、 OriginalTransactionId カラムに NULL_FILTERED オプションを指定した UNIQUE INDEX を張っています。これにより、 OriginalTransactionId が NULL のイベント (通常の会計イベント) は重複扱いされず、NULL でない値だけに一意性制約がかかります。 これらをまとめると、スキーマの該当箇所は次のようになります。 CREATE TABLE AccountingEvents ( EventId STRING(100) NOT NULL, AccountingCode STRING(MAX) NOT NULL, OriginalTransactionId STRING(100), -- ... ) PRIMARY KEY (EventId); CREATE NULL_FILTERED INDEX AccountingEventsByOriginalTransactionId ON AccountingEvents(OriginalTransactionId); CREATE TABLE JournalEntries ( Id STRING(100) NOT NULL, EventId STRING(100) NOT NULL, IsReversal BOOL NOT NULL, -- ... ) PRIMARY KEY (Id); 仕訳作成での借方/貸方の入れ替え 逆仕訳の仕訳作成は、元の取引と同じ仕訳ルールを再利用しつつ、ルールから取り出した借方と貸方の属性をコード側で入れ替えて行います。 会計イベントには取引の方向を表す ItemKey という識別子が含まれ、 X.to.Y の形式を取ります。仕訳ルールもこの ItemKey をキーに登録されています。逆仕訳イベントでは ItemKey が反転して Y.to.X の形で届くため、ルールを検索する際は ItemKey を一度元の向きに戻します。 ルールを取得した後、逆仕訳イベントの場合に限り、ルールから取り出した借方と貸方の各属性をコード側で入れ替えます。簡略化した擬似コードで示すと次のようになります。 originalRule := getOriginalEventRule(ev) debitAccountingTitleCode := originalRule.DebitAccountingTitleCode creditAccountingTitleCode := originalRule.CreditAccountingTitleCode debitXxx := originalRule.DebitXxx creditXxx := originalRule.CreditXxx // ... if isReversal { debitAccountingTitleCode, creditAccountingTitleCode = creditAccountingTitleCode, debitAccountingTitleCode debitXxx, creditXxx = creditXxx, debitXxx // ... } debit := JournalEntry{ AccountingTitleCode: debitAccountingTitleCode, Xxx: debitXxx, // ... IsReversal: isReversal, } credit := JournalEntry{ AccountingTitleCode: creditAccountingTitleCode, Xxx: creditXxx, // ... IsReversal: isReversal, } 仕訳ルールが借方と貸方の対称な構造を持っているため、ItemKey の反転とそれに続く借方と貸方の入れ替えという 2 つの操作だけで逆仕訳を作成でき、実装はシンプルな修正で済みました。 会計レポートでの逆仕訳の打ち消し 会計レポートに打ち消しを反映するには、集計クエリで逆仕訳または逆仕訳の会計イベントを判定し、正しく金額を計算する必要があります。 JournalEntries から集計するクエリでは IsReversal フラグで判定し、分析用に AccountingEvents から集計するクエリでは OriginalTransactionId IS NOT NULL で判定します。 会計ドメインの前提で述べたとおり、本来の逆仕訳は、勘定科目ごとに借方と貸方を足し合わせて相殺することで打ち消しを実現します。ただし、会計レポートには借方または貸方のどちらか一方だけを集計するものもあり、そうしたレポートでは足し合わせによる相殺は成立しません。そのため、集計クエリで逆仕訳または逆仕訳の会計イベントの金額を符号反転して合算するアプローチをとっています。なお、逆仕訳の金額自体は元の仕訳と同じ正の値で保存しています。 会計レポートは、対象とするテーブルや集計の意味合いがそれぞれ異なるため、共通化が難しく、もともと個別に実装されています。逆仕訳の打ち消しを反映するためには、その個別実装それぞれに修正を入れる必要がありました。例えば WHERE 句の絞り込み条件を、逆仕訳の会計イベントも拾えるように拡張するなどです。すべてのレポートに対してこのような修正を加えていったため、実装と検証には時間がかかりました。 逆仕訳バッチによる運用の自動化 加えて、誤って登録された会計データを訂正するために、逆仕訳を作成するためのバッチを新たに実装しました。このバッチは、対象となる取引の ID リストを受け取り、それぞれについて、上流のマイクロサービスの API を介して打ち消し用の取引を作成します。それが会計イベントとして本システムに登録され、逆仕訳が自動的に作成されます。 個別の取引で失敗が発生した場合はログに記録しつつ、残りの処理は継続します。また、冪等性は上流のマイクロサービス側で保証されているため、同じ ID リストで再実行することも可能です。 これにより、誤ったデータの打ち消しをシステム上で自動的に逆仕訳として反映できるようになり、これまで手作業で行っていた特定・連携の負荷と作業ミスのリスクが大きく軽減されました。 おわりに 本稿では、会計データの訂正を支える逆仕訳機能の設計と実装を紹介しました。 会計のように不変性の制約が強いドメインで開発していると、ドメインの原則が設計判断をそのまま導いてくれる場面が多く、その面白さを今回の機能開発でも改めて感じました。 今回の機能開発は、Accountingチームが抱える運用負荷削減という大きな取り組みの一環でもあります。会計システムは、会社が財務状況を正しく把握するための基盤であり、事業の成長に合わせてスケールできる状態に保ち続ける必要があります。これからも、持続的な開発ができるよう取り組んでいきたいと考えています。 次の記事は imamu さんです。引き続きお楽しみください。
こんにちは、Merpay の Payment Core チームと Payment Solution チームで Engineering Manager (EM) をやっている komatsu です。普段は決済基盤や決済体験の開発をするチームを見ていたり、最近は PCP Foundation というチームを発足して Individual Contributor (IC) として基盤の整備や AI 周りのツールの導入も行っています。 この記事は Merpay & Mercoin Tech Openness Month 2026 の 4 日目の記事です。 この記事では、私たちの Payment Platform が「決済」と「会計」のあいだに置いている共通言語 MoneyFlow と、それを支えるために開発したツール群 mfgen (MoneyFlow Generator; /ˌemefˈdʒen/) について紹介します。一見すると別物に見える「システムとしての決済基盤」と「上場企業として厳密さが求められる会計」ですが、実際には深く結びついています。私たちは、その複雑な関係を整理し、経理・PdM・エンジニアといった異なる立場の人々が共通の理解を持てるよう、MoneyFlow という共通言語を整備してきました。この一年ほどで MoneyFlow は徐々に組織へ浸透し、現在では同じフォーマットで記述し、同じフォーマットでレビューする開発プロセスが少しずつ定着しつつあります。本記事では、その取り組みの背景と、それを支える mfgen の仕組みについて深掘りしていきます。 決済プラットフォームと会計のつながり メルカリグループの Payment Platform は、メルカリのマーケットプレイス (Consumer to Consumer; CtoC) だけでなく、メルカリShops やメルペイ、メルコイン、メルカリモバイル、メルカリAds、グローバルアプリなど、「お金が動くすべてのプロダクト」の土台となり、決済にまつわる汎用的な機能を提供しています。決済そのものについての記事は多く上がっているので、興味があれば メルカリのグローバル展開を支えるPayment Platformの進化 や Payment に関するその他の記事 もあわせてご一読ください。 また、Payment Platform チームが掲げる理念の一つに「会計も含めた決済ソリューション」というものがあります。決済の一回一回は、お客さまや Payment Platform の利用者 (つまりプロダクトのマイクロサービス) から見れば「支払いが完了した」という体験です。しかし会社や経理から見れば、それは会計上の仕訳として正しく記録しなければならない事象でもあります。そこで Payment Platform は、外向きの決済 API を提供するだけでなく、その裏側で「お金がどう動き、どの勘定にどう記録されるか」までを引き受け、記帳や会計レポートの発行を通した社内向けの統合的な決済ソリューションとして存在しています。これによってプロダクトはビジネスロジックの開発に集中し、会計にまつわる大半の連携を Payment Platform に委ねることができます [^1]。 たとえば、メルカリのシンプルな購入ひとつを取っても、段階ごとにお金が動き、それぞれに対応する会計上の記録が発生します。お客さまが支払う場面では、残高を使う場合は購入者の資金移動口座からエスクロー決済の預かり金へと振り替え、メルペイのクレジットを使う場合はあと払い債権を計上し、他社クレジットカードを使う場合は PSP (Payment Service Provider) に対する未収入金として処理します。そして取引が完了して売上が立つ段階では、預かり金を取り崩し、出品者の資金移動口座と手数料収入へと振り分けます。 これはまだ単純な例ですが、実際には、コンビニ決済のような非同期な決済手段、キャンセルや返金、資金移動口座の開設有無、クーポン等による値引き、為替が絡む決済——こうした条件の組み合わせやタイミングの違いが絡み合い、仕訳はもっと複雑になります。特に、ひとつの API 呼び出しが複数の勘定に影響することもあれば、逆に、会計上はひとつの動きが複数の API にまたがることもある、という点も複雑性を増す要因となっています。この「ズレ」があるために、エンジニアと経理が直接話しても認識が噛み合わず、議論が長引くということが頻繁に起きていました。エンジニアが「決済 API のフロー」として見ているものを、経理は「仕訳」として見ており、同じ事象を、まったく異なる言語で眺めているからです。 MoneyFlow という共通言語 このすれ違いの根本にあるのは、Payment Platform のエンジニアと経理のあいだに横たわるドメイン的な距離です。両者は使う言葉からして異なり、エンジニアが API やエンドポイントで語るのに対し、経理は勘定科目や仕訳で語ります。前提とする知識も、システムの内部構造と会計基準というように噛み合いません。そのうえ、API と勘定科目が必ずしも 1:1 で対応しないため、片方の言葉をそのまま翻訳しても意味が通じないことがままあります。 この距離を埋めるために、Payment Platform で直近約一年にわたって運用しているのが MoneyFlow です。これはシステム上のお金の動きを表現するためのフロー図であり、同時に、エンジニア・経理・Product Manager (PdM) の理解を揃えるための共通言語でもあります。MoneyFlow は主に三つの要素で構成します。一つ目の Actor は取引の主体(誰が価値を出し、誰が受け取るのか)で、User (購入者や出品者)や Partner (加盟店さま等取引の相手方。社外事業者に限らず、会社として Mercari / Merpay / Mercoin もシステム上は Partner) が該当します。二つ目の Account は各 Actor が持つ勘定 (ledger) で、 USER_FUNDS や PARTNER_SALES 、 USER_DEBT 、 PARTNER_CLEARING_SALES などがあります。三つ目の Flow はおおむね 1 回の API 呼び出しに相当するお金の移動を表し、Source (from) と Target (to) を持つことで、その決済でお金がどこからどこへ動くのかを示します。さらに各 Flow は、勘定科目に対応する accounting code を持ちます。 こうした図として表現することで、エンジニアは価値交換を行う API を記述でき、経理は Flow を中心とした会計観点でのお金の動きを把握できます。図の Actors section は Actor の一覧とそれぞれが持つ Account (ledger) を表現し、MoneyFlow section は商流ごと・API ごとのお金の動きを表現します。 この一年ほどをかけて、MoneyFlow は徐々に組織に浸透してきました。現在では、経理・PdM・エンジニアが同じフォーマットで書き、同じフォーマットでレビューする、といった開発の工程が少しずつスタンダードになりつつあります。著者自身もプロジェクトや開発の最初のフェーズでこれを行うことで、ステークホルダーと認識を合わせやすくなったと実感しており、Design Doc に並ぶ重要なドキュメントだと感じています。さらに、エンジニアリングと会計の両面を表現する概念を持ったことで、エンジニアは会計のドメインを、経理はエンジニアのドメインを理解しやすくなりました。エンジニアが勘定科目について経理に相談したり、経理が API 名を使って商流を表現したりと、お互いの歩み寄りがかなり加速したと感じています。 mfgen CLI — DSL による MoneyFlow の標準化 組織への浸透が進む一方で、MoneyFlow は最初からフォーマットが決まっていたわけではありません。初期は Draw.io (Diagrams.net) で手描きしていました。これはこれで描けるのですが、いくつかの課題がありました。まず、書き方が人によって異なり、解像度や粒度が書き手に依存するため、図にばらつきが出ます。次に、そもそもどう書けばよいか分からない人がほとんどで、書ける人が限られていました。書ける人がいないプロジェクトでは、会計観点の考慮漏れを見落とすこともありました。さらに、Draw.io は XML で表現できるものの human-friendly な宣言的記法が存在せず、同じフォーマットで安定して描画することや AI agent との親和性に欠けていました。 そこでまず取り組んだのが、MoneyFlow を YAML の DSL として定義することでした。そして、その YAML から Draw.io (および PNG 画像) を生成する CLI ツール mfgen を開発しました。YAML は次のようなイメージです。 name: Example Payment Flow actors: - id: user name: User type: USER - id: partner name: Partner type: PARTNER accounts: - id: user_funds owner: user account_type: USER_FUNDS currency: JPY - id: partner_sales owner: partner account_type: PARTNER_SALES currency: JPY flows: - id: MF1 name: Payment api: Payment.CreateCharge currency: JPY accounting_code: xyz from: - id: user_funds amount: 1000 to: - id: partner_sales amount: 1000 描画のクライアントとしては依然として Draw.io を使っているため、CLI の実装の中身はかなり地味なものです。YAML をパースし、描画に必要な座標を計算し、Draw.io の XML を組み立てる——レイアウトのための座標計算や XML 生成を、ひとつずつ実装した形になります。この段階では、validation を強めに設けて一貫性を高めることと、AI agent による生成を簡単にすることを目的としました。具体的には、次のチェックを行います。 必須フィールドを検証する ID の重複を検出する 参照整合性を確認する (actors の owner、flow の from / to などが実在するか) セマンティクスを検証する ( from の合計と to の合計が一致するか、 PARTNER_DEBT / USER_DEBT に debt_type があるか、など) さらに、GitHub Actions で YAML を自動変換して Draw.io 形式および画像でアップロードする CI を組みました。これによってレビューや共有、過去の MoneyFlow の管理が簡単になり、MoneyFlow が蓄積されることで次の MoneyFlow 作成時の AI による精度も向上していきました。 mfgen Web — リアルタイム共同編集の実現 DSL 化によって標準化は大きく進みましたが、mfgen CLI にも残された課題がありました。変換が YAML → Draw.io の片方向であるため、図を見ながら直接編集して保存する、という使い方ができません。ミーティング中に Draw.io を直接編集したりコメントを付けたりすると、それを YAML に取り込むコストが発生します。そして、Draw.io の表現力そのものが上限になる場面もありました。 そこで開発したのが mfgen Web です。これは社内にホストしている Web ツールで、「Draw.io のような同時編集」と「YAML による宣言的な定義」の両方を実現します。具体的には、三つの編集方法を備えています。Canvas 上で直接描く方法では、Draw.io のようにノードを配置してつなぎます。MoneyFlow に特化した編集機能では、Actor / Account / Flow といったドメイン概念をそのまま編集できます。そして YAML を直接記述する方法では、エディタで宣言的に書けます。 これらはすべて双方向に同期します。YAML を更新すれば図に即座に反映され、図をドラッグすれば YAML にも反映されます。さらに、複数人が同じ MoneyFlow をリアルタイムで共同編集でき、メンバーが作成した MoneyFlow はカタログとして蓄積されていくため、チームのドキュメンテーションとしても活用できる形になりました。 この手の内製ツールは Vibe Coding でおおよそ作れてしまう時代ですが、技術的なポイントを簡単に紹介します。 サーバは役割の異なる 2 つに分かれています。 api サーバ (Hono) が SPA の配信と REST API を担い、 collab サーバ (Hocuspocus) が WebSocket での同時編集を担います。設計上のポイントは次のとおりです。 Single Source of Truth として Y.Doc (Conflict-free Replicated Data Type; CRDT) を採用する。サーバが権威ある Y.Doc を保持して各クライアントと同期し、YAML はサーバ側で Y.Doc から投影して生成する派生物とすることで、クライアントが直接書き込むことはありません。これにより、YAML を入力とする CLI 版 mfgen とのデータ互換性を保っています。 「追記ログ + スナップショット」方式で永続化する。編集は append-only な update ログに追記し、2-10 秒程度の debounce で Y.Doc 全体のスナップショットと生成した YAML を Postgres に書き込み、古い update を圧縮 (compaction) します。 Canvas 上の座標を YAML から除外する。位置情報を YAML から外すことで、既存 CLI と等価なデータに保っています。 Origin タグ (5 種) で編集の出どころを区別する。「UI 操作」「YAML 編集」「ドラッグ操作」「他クライアントからの更新」「初期同期」をタグで見分け、双方向同期で起こりがちな無限ループを防ぎつつ、Undo の対象も制御しています。 Redis (Memorystore) の pub/sub で collab サーバ間のメッセージを fan-out する ( @hocuspocus/extension-redis )。複数レプリカ運用に備えた仕組みで、単一レプリカであれば Redis なしでも動作します。 技術スタックは TypeScript / Bun に統一しています。フロントエンドは React + Vite + @xyflow/react + CodeMirror + Yjs、サーバは Hono + Hocuspocus + Drizzle ORM、データストアは Postgres と Memorystore Redis という構成です。これらを専用の Google Cloud プロジェクト上で、api / collab それぞれ独立したサービスとして運用しています。 mfgen Web はまだ開発したばかりで活用事例はありませんが、Web 版になったことで、MoneyFlow はより実践的なツールになると感じています。今後さらに開発を続け、エディタ内に Claude Code SDK などで AI を搭載したり、経理によるレビュー後にシステムへ登録するところまで E2E で自動化できるようになれば、会計がプロダクト開発のブロッカーにならず、基盤として素早いサポートができるようになると信じています。 まとめ この記事では、Payment Platform が「会計も含めた決済ソリューション」であること、そしてエンジニアと経理のあいだを繋ぐ共通言語 MoneyFlow と、それを実践的に運用するための mfgen について紹介しました。MoneyFlow を共通の出発点に置いたことで、これまで噛み合わなかったエンジニアと経理の議論は、同じ図を指しながら進められるようになりました。考慮漏れが減り、お互いがお互いのドメインに歩み寄る——そうした定性的な変化が、この直近多く見られるようになったと感じています。 著者自身、会計はまだ勉強中の身ですが、システムと結びつけて考えるとかなり理解しやすくなる、というのが正直な実感です。そして、こうした内製ツールは AI の力を借りて本当に手軽に作れる時代になりました。私が所属する PCP Foundation チームでは、今後もこうした組織横断のツール開発とメンテナンスを通じて、チームの垣根を越えた生産性の向上につなげていきたいと思っています。 [^1]: 決済 API を利用するタイミングと会計連携のタイミングが異なる場合はプロダクトが直接行っているが、この深掘りと進化はまた別の機会に。 次の記事は hokao さんの「会計システムにおける訂正機能の設計と実装」です。引き続きお楽しみください。
こんにちは、いつも心に冪等性 sinmetalです。「 Merpay & Mercoin Tech Openness Month 2026 」の3日目の記事です。 本記事では、MySQLで動いていた「お客さまが所持するクーポン一覧取得」クエリをSpannerへ移行した際に直面したパフォーマンス問題と、その解決までの過程を紹介します。 DBごとのアーキテクチャの差 MySQLとSpannerはどちらもSQLを利用できますが、アーキテクチャが大きく異なるので、テーブルやクエリの設計は異なります。移行する時は差異があるSQLを修正するだけでなく、各DBのアーキテクチャまで意識して、実装を見直す必要があります。 MySQLのアーキテクチャ MySQLはPrimary InstanceがWriteを担当するのでWriteをスケールさせたいなら、Primary Instanceのマシンを強化します。 ReadはRead Replicaを増やすことでもスケールさせることができます。 Read Replicaは独立しているので、OLAPのような特定のクエリを特定のInstanceで実行することもできます。 Spannerのアーキテクチャ Spannerはデータを分割してSplitに保存します。Splitはデータサイズや負荷で自動的に増減し、データの分散範囲も調整されます。Writeに対してもスケールできますが、Read Replicaが無いので特定のクエリを特定のInstanceで処理するようなことはできません。Read Replicaだけを増やすということもできないので、Read性能だけを増やすこともできません。WriteもReadもどちらもAuto Scaleして分散して処理するのが強みです。 メルカリのクーポン機能の移行 クーポン機能の中に自分が所持しているクーポンの一覧を取得するAPIがあります。その中で実行されていたクエリのチューニングを行いました。 まずはMySQLの実装をそのまま移行したので、以下のようなクエリになっていました。 MySQLなら、それほど問題になるようなクエリではないかもしれませんが、Spannerの実行計画を見るとResidual Condition(Residual Conditionはインメモリで処理する必要がある状態です。後述※1)が必要、Sortが必要など非常に厳しい状態です。 SELECT co.CouponOwnerID, co.CouponID, co.Expire, co.CreatedAt FROM CouponOwners co JOIN Coupons c ON co.CouponID = c.CouponID WHERE co.UserID = @userID AND c.IsActive = @isActive AND co.Expire > @now AND c.StartDate <= @now AND co.Used = @isUsed AND c.Type IN UNNEST(@couponType) AND c.MarketPlace = @marketPlace ORDER BY co.CreatedAt DESC +-----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | ID | Query_Execution_Plan | +-----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | *0 | Distributed Union (distribution_table: CouponOwnersByUserIDUsedExpireCreatedAtDESC, execution_method: Row, preserve_subquery_order: true, split_ranges_aligned: false) | | 1 | +- Serialize Result (execution_method: Row) | | 2 | +- Sort (execution_method: Row) | | *3 | +- Distributed Cross Apply (execution_method: Row) | | 4 | +- [Input] Create Batch (execution_method: Batch) | | 5 | | +- RowToDataBlock | | 6 | | +- Local Distributed Union (execution_method: Row) | | *7 | | +- Filter Scan (execution_method: Row, seekable_key_size: 3) | | *8 | | +- Index Scan (Index: CouponOwnersByUserIDUsedExpireCreatedAtDESC, execution_method: Row, scan_method: Row) | | 34 | +- [Map] Cross Apply (execution_method: Row) | | 35 | +- [Input] KeyRangeAccumulator (execution_method: Row) | | 36 | | +- DataBlockToRow | | 37 | | +- Batch Scan (Batch: $v2, execution_method: Batch, scan_method: Batch) | | 46 | +- [Map] Local Distributed Union (execution_method: Row) | | *47 | +- Filter Scan (execution_method: Row, seekable_key_size: 0) | | *48 | +- Table Scan (Table: Coupons, execution_method: Row, scan_method: Row) | +-----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ Predicates(identified by ID): 0: Split Range: (($UserID = @userid) AND ($Used = @isused) AND ($Expire > @now)) 3: Split Range: ($CouponID_1 = $CouponID) 7: Residual Condition: ($UserID = @userid) 8: Seek Condition: (IS_NOT_DISTINCT_FROM($UserID, @userid) AND ($Used = @isused)) AND ($Expire > @now) 47: Residual Condition: (($IsActive = @isactive) AND ($MarketPlace = @marketplace) AND ($StartDate <= @now) AND ($Type IN @coupontype(array))) 48: Seek Condition: ($CouponID_1 = $batched_CouponID') DB Schema CREATE TABLE Coupons ( CouponID STRING(36) NOT NULL, Type STRING(36) NOT NULL, IsActive BOOL NOT NULL, StartDate TIMESTAMP NOT NULL, MarketPlace STRING(255) NOT NULL, ) PRIMARY KEY(CouponID); CREATE INDEX CouponsByIsActiveStartDateTypeMarketPlaceID ON Coupons(IsActive, StartDate, Type, MarketPlace); CREATE TABLE CouponOwners ( CouponOwnerID STRING(36) NOT NULL, Used STRING(10) NOT NULL, UserID INT64, CouponID STRING(36) NOT NULL, Expire TIMESTAMP NOT NULL, CreatedAt TIMESTAMP NOT NULL OPTIONS ( allow_commit_timestamp = true ), UpdatedAt TIMESTAMP NOT NULL OPTIONS ( allow_commit_timestamp = true ), ) PRIMARY KEY(CouponOwnerID); CREATE INDEX CouponOwnersByUserIDUsedExpireCreatedAtDESC ON CouponOwners(UserID, Used, Expire, CreatedAt DESC) STORING (CouponID); 実行計画には現れない問題もありました。 HotSpotです。 Coupons Tableはクーポンの情報が書かれているマスタテーブルなのですが、件数はそれほど多くありません。 SpannerはRead Replicaが無いため、小さなTableや特定のRowへの高頻度の読み取りがMySQLと比べると不得意です。対象のRowが存在するSplitに負荷が集中してしまうからです。 特に負荷が集中するものとしてメルカリのすべてのお客さまに配信する 超メルカリ市土日限定クーポン がありました。 自分が所持しているクーポンの一覧を取得するとCoupons Tableの該当のRowがJOINのために参照されます。 すべてのお客さまが持っていて、しかも、超メルカリ市という大きなキャンペーンの中で、特定の土日にだけ使えて、クーポン付与のタイミングでプッシュ通知も行われることもあり、高い確率でHotRowになります。 Note: HotRowとは? アクセスが集中していてそれ以上分割不能なRowのことを指します。 HotRowが発生しているかはHot Spot Statisticsを見ると分かります。 開発環境でチェックする場合は、負荷テストを行い、Hot Split Statisticsをチェックします。 https://docs.cloud.google.com/spanner/docs/introspection/hot-split-statistics?hl=en#hot_row パフォーマンスチューニング これらの問題を解決するためにアーキテクチャを見直しました。 やったことは大きく分けて3つです。 Coupons TableのJOINをやめて、アプリ側に処理を寄せた ORDER BYを削除した NULLを許可しているColumnのFilter条件を調整してResidual Conditionが発生しないようにした Coupons TableのJOINをやめて、Coupons Tableはアプリケーション側で参照するようにし、更に一定期間メモリ上にキャッシュするようにしました。 ValkeyやRedisに保存することも検討しましたが、アクティブな状態のクーポンの数は数十程度であること、変更はほぼないことで、メモリ上に持ってしまって良いだろうと判断しました。 今後、他にもキャッシュしたいものが増えれば、専用のインフラを用意して、実装し直すかもしれません。 キャッシュを入れたことで、HotRowが発生しづらくなり、クエリもCouponOwners Table単体で処理できるようになり、JOINも不要になりました。 次に ORDER BY co.CreatedAt DESC を無くしました。 Filter条件として Expire > @now があるので、CreatedAtのSortがあると単一のIndexをSeek Conditionで読むだけという処理にはできません。 ExpireをResidual ConditionでFilter Scanするか、CreatedAtをSortするかを選択することになります。 1人のお客さまが持っているクーポンの数は多くても10程度なので、Sortしてしまっても良いかなとは思いましたが、呼び出し回数が非常に多いAPIなので、アプリケーション側に処理を寄せています。代わりにアプリケーション側の負担が増えているので、どちらがよいかは悩ましいところです。今後の状況によってはSortをSpanner側に戻すこともあるかもしれません。 もう一工夫している点として、UserIDのFilter条件 (co.UserID IS NULL AND @userID IS NULL) を追加しています。 これはCouponOwners.UserIDはNOT NULL制約がないため、@userIDにNULLが入る可能性をSpannerが考慮して、Filter ScanとしてResidual Condition: ($UserID = @userid)を追加してしまうのを抑制するためです。(元の実行計画の*7)(後述※2) 結果としてクエリの実行計画は非常にシンプルなものにできました。 SELECT co.CouponOwnerID, co.CouponID, co.Expire, co.CreatedAt FROM CouponOwners co WHERE (co.UserID = @userID) OR (co.UserID IS NULL AND @userID IS NULL) AND co.Expire > @now AND co.Used = @isUsed +----+-----------------------------------------------------------------------------------------------------------------------------------------+ | ID | Query_Execution_Plan | +----+-----------------------------------------------------------------------------------------------------------------------------------------+ | *0 | Distributed Union (distribution_table: CouponOwnersByUserIDUsedExpireCreatedAtDESC, execution_method: Row, split_ranges_aligned: false) | | 1 | +- Local Distributed Union (execution_method: Row) | | 2 | +- Serialize Result (execution_method: Row) | | 3 | +- Filter Scan (execution_method: Row, seekable_key_size: 3) | | *4 | +- Index Scan (Index: CouponOwnersByUserIDUsedExpireCreatedAtDESC, execution_method: Row, scan_method: Row) | +----+-----------------------------------------------------------------------------------------------------------------------------------------+ Predicates(identified by ID): 0: Split Range: (($UserID = @userid) OR (ISNULL($UserID) AND ($Used = @isused) AND ($Expire > @now) AND ISNULL(@userid))) 4: Seek Condition: (($UserID = @userid) OR (ISNULL($UserID) AND ($Used = @isused) AND ($Expire > @now) AND ISNULL(@userid))) まとめ 元のクエリと比べると非常にシンプルになり、高速で負荷の小さなものになりました。SQLが利用できるDBは増えていますが、インターフェースとしてSQLが使えても中のアーキテクチャがそれぞれ異なります。 Spannerは予算さえあれば気軽にNodeを増やせるため、LatencyやCPU使用率に不満があれば、Nodeを増やすことで解決もできます。 しかし、コスト効率はよくないですし、HotSpotのようにNodeを増やしても解決できない問題もあります。 Statistics tables を定期的に確認し、パフォーマンスの状態をモニタリングすること。 新しいクエリの作成や既存のクエリの修正を行う場合は実行計画を確認すること。 アクセスパターンに対してSpannerがどんな挙動をする必要があるかSpannerの気持ちになって考えること。 これらを日常的に行うことで、よりよいSpannerライフが送れます。 次の記事は komatuさんの「決済プラットフォームと経理を繋ぐ MoneyFlow」です。引き続きお楽しみください。 ※1 FilterScan operator の Seek ConditionとResidual Condition Seek ConditionはTableやIndexのスキャン範囲の開始と終了地点が特定できている状態の時に使われます。開始地点から終了地点まで読み込むだけなので、高速に動作します。 Residual ConditionはTableやIndexを読み込む時に開始地点と終了地点が特定できず、データを実際に読んでFilterする必要がある時に利用されます。Seek Conditionと比べるとCPUを多く消費しますし、スキャンするデータ量に応じてLatencyも増加します。 Seek ConditionとResidual Conditionについては Cloud Spanner Unofficial Hacks を読むとよいでしょう。 ※2 IS NOT DISTINCT FROM SpannerにはIS NOT DISTINCT FROMがないので、co.UserID IS NULL AND @userID IS NULLを入れているわけですが、2026年5月25日に再度試したところIS NOT DISTINCT FROMで動作していました。まだ、ドキュメントには反映されてないので、記事の中では使っていませんが、近々リリースノートが出るのでしょう。