every Tech Blog

株式会社エブリーのTech Blogです。

DroidKaigi 2026 に参加してきました!

はじめに

2026年に開催された DroidKaigi 2026 に、弊社の開発本部から 3 名のエンジニアが参加してきましたので、イベントの様子や印象に残ったセッションをご紹介します。

イベントの様子

スポンサーブース

エブリーは今回、ゴールドスポンサーとしてブースを出展させていただきました!

足を運んでいただいた皆様、本当にありがとうございました!

ブース企画

アンケートボード

ブースでは、「AI時代!どこまで越境したいですか?」をテーマにした参加型のアンケートボードを実施しました!

Android開発をベースにしつつ、バックエンドやPdM、データサイエンスといった他の領域へどのようにスキルを広げていきたいか、皆様のリアルな声を聞かせていただきました!

回答いただいた多くの皆様、ありがとうございました!最終結果はこちらです……!

2日間でいただいたシールは、合計およそ 400 枚。領域ごとの内訳は次のようになりました。

最も票が集まったのは「Android」でしたが、Backend と iOS がほぼ同数で並び、この3つが上位を分け合う形になりました。一方で全体を見ると、Android 以外の領域に貼られたシールは全体の 8 割弱。「Android を軸に据えつつ、その外側にも手を伸ばしていきたい」という方が多数派でした。

また、シールを貼っていただきながら、こんな声も聞かせていただきました。

モバイル領域が好きなので、クロスプラットフォームでやっていきたい

コードは AI が書いてくれるので、プロダクトをどうグロースさせるかを考えられるようになりたい

「AI 時代にどう越境するか」という問いに対して、技術の横方向に広げていく方向と、プロダクトづくりそのものへ踏み込んでいく方向、その両方のリアルな声を伺うことができました。

※シール数は写真からの集計のため、概算値です。

Xフォロー&くじ引き

エブリー開発部の X アカウントをフォローいただくと、くじを1回引けるという企画も実施しました。

景品は、レンジ調理鍋・まな板・計量スプーン・しゃもじ・お箸など、普段の料理で使えるキッチングッズです。

ハズレの方にも、CTO 自らがテイスティングして選んだ「CTO ブレンド」のコーヒーをお渡ししていたので、くじを引いてくださった方には全員何かしらお持ち帰りいただけるようにしていました。

キッチングッズが当たった方に喜んでいただけて、こちらも嬉しかったです!

またXをフォローいただいた皆様、本当にありがとうございました!X ではテックブログの更新情報も発信しているので、ぜひチェックしていただけると幸いです!

ネイル体験会

会場ではプロのネイリストによるネイル体験会が開催されており体験してきました!

流れとしては、ネイルをする指を2本選び、それぞれのデザインを決めていくというもの。ベースカラーはネイリストの方と相談しながら決められるので、ネイルに詳しくなくても安心して選ぶことができました。

デザインは、DroidKaigi のキャラクター3種類とロゴの中から好きなものをチョイスできました。指先に DroidKaigi のキャラクターがいてくれるので、ふとした拍子に目に入るたび嬉しくなります。

他社のスポンサーブース

REALITY さん

REALITY さんは、AEP 対応に関するアンケートを行っていました!
AEP (Apps Experience Program)は、Google が指定した要件を満たすと認定を受けられ、Google Play の新しい料金表の適用などの特典が得られるプログラムです。
Material3 は対応済み (80% 以上) の回答が多く、予想以上でした。一方でフルコンポーズ化は、まだ対応中・検討中という回答の方が多いようでした。
アーキテクチャも公開されていました。3D アバター以外の箇所はネイティブで作成されているとのことで、Unity を使っていると思っていたので驚きました。

BIZREACH さん

BIZREACH さんは、AI が進化して楽になったことについてアンケートを行っていました!
テストコードの生成やエラーの原因調査のような、コーディング業務の補佐的な立ち位置に留まらず、相談相手として活用している方が多く面白かったです。
晩ご飯の献立については、他と比べると少ないようでした。デリッシュキッチンの出番のようです!

エムスリー さん

エムスリーさんは、毎年恒例の、プログラムのコードが印刷されたクリアファイルを配布されていました。
なんと去年よりコードが短くなっているとのことでした!
クリアファイルの詳細については、昨年版のものになりますが エムスリーさんのテックブログ で公開されていますので、ぜひご覧ください!

セッション紹介

なんとかする力 〜Androidエンジニアからマネージャー、さらにその先へ?〜

発表者: m.coder さん(フラー株式会社)

レポート: 岡田

m.coder さんに、「目の前の課題を『なんとかする』の積み重ねが今の自分を作ってきた」という考えをもとに、キャリアとの向き合い方を語っていただいたセッションでした。

仕事のやりやすさは「何を・いつまでに・どこまでやるか」が決まっているかで大きく変わり、曖昧な箇所を明確にして不確実性を下げること自体が価値ある仕事だという話から始まりました。

印象的だったのは、役職が上がっていくにつれ、皆等しく抽象度の高い課題の解決を求められるというお話です。「なんとなくチームの雰囲気が悪い」「なんかプロジェクトの品質が悪い気がする」といった、課題かどうかすら曖昧なものを扱う必要があるという具体例に痛く納得しました。こういった漠然とした課題については、どうしても目を瞑りがちなので、自身のマインドセットを見直す必要があるなと痛感しました。

またテックリードとマネージャーは向き合い方が違うだけで、どちらも「自分以外の領域(チームや組織)をなんとかする」役割だという整理も面白かったです。

自分のキャリアを考えるうえで、抽象度の高い問題に立ち向かうべきという方針や、それを実現させる方法について非常に学びになりました。ご自身の経験から語られている箇所も多く、熱いメッセージをいただいた気持ちになりました。

また冒頭で『エンジニアリング組織論への招待』を紹介していただきました。1 章だけでも読む価値があるとのことなので、ネクストアクションとしてはこちらを読もうと思います。

あなたのANRはどこから? — 発生する仕組みを診断し、症状別に処方する

発表者: chomi さん(NRIネットコム株式会社)

レポート: 岡田

会場が皆うなずいていたセッションだったように思えます。

メインスレッドについての解説を経て、まずは誰しもが経験したことがあるであろう、メインスレッドでの I/O についてのお話から始まりました。その後起動時の重い初期化、ロック競合と進みました。

起動時の重い処理については、特にレガシーコードを触ったことがある人なら対応したことがあるのではないかと思います。本当に Application で初期化すべきかを考えるというのは ANR 以外にも、パフォーマンスの観点から非常に重要です。例として FirebaseSDK の初期化について出ましたが、こちら誰しもがなんとかならないかなと調べたことがあると勝手に思っているので面白かったです。また固有端末依存や Binder 経由の呼び出し先での ANR などについても話があり、やはり皆さん困っているのだなと共感しました。

何より構成と見せ方が完璧だったと思います。スライドは要点だけが目に入る作りで、定義や例などもとても丁寧でしたので、スッと内容が入ってきました。終章の ANR 診断フローチャートについても綺麗にまとめられており、参考になりました。

発表での再現には、公開されているサンプルアプリ DorodoroTimer を用いたそうです。デモモードをONにすると上記の ANR が実際に発生し、コード内の [ANR-xx] マーカーから問題箇所と修正版を見比べられます。

AndroidにおけるServer-Sent Events: 工場の現場を生き抜くリアルタイムストリーム

発表者: Mr. Jasveen Sandral (Industrial Android, Toyota Group Japan)

レポート: 鈴木 (@0muji4_eng)

本講演は、AndroidにおけるServer-Sent Events(SSE)を用いたリアルタイムストリーミング実装の課題と、その具体的な解決策について論じています。

Webブラウザとは異なり、Androidの標準的なライブラリ(OkHttpなど)にはSSEの自動再接続や状態管理の機能が不足しており、通信障害時にエラーを検知できず画面のデータがフリーズしてしまうエラーケースが存在します。講演者はこの事象を "The Trap of Silence" (沈黙の罠) と呼んでいました。この問題を克服するためには、サーバーに依存するのではなく、クライアント側(Android側)で堅牢な自己回復機能を持つ独自の仕組みを設計する必要性が生じます。

具体的には、サーバーからの定期的な通信(ハートビート)を監視してタイムアウトなどの切断を検知する仕組みや、厳密な状態管理(ステートマシン)の実装が不可欠です。あわせて、再接続時には最後に受信したID(Last-Event-ID)をサーバーへ送信することで、通信断絶中のデータ欠落を補完し、安全にストリーミングを再開する必要があります。

また、頻繁な双方向通信に適したWebSocketとの技術的な比較や、端末がオフラインになった際の適切なUI制御にも触れられています。最終的に、一方向のデータ監視システムにおいてSSEを有効に活用するには、サーバー側でのバッファリングといった設計だけでなく、クライアント側がいかにして通信の切断と復帰に耐えうるアーキテクチャを構築できるかが重要であると結論付けています。

WebAssembly in Android Apps 〜 WASMはJNIの夢を見るか

発表者: keiji_ariyama さん (C-LIS CO., LTD.)

レポート: 鈴木 (@0muji4_eng)

本講演は、Androidアプリ開発において、C++などで書かれた既存のネイティブライブラリ(OpenJPEG など)を、WebAssembly(Wasm)を用いて安全に再利用するためのアーキテクチャ設計について論じています。

背景として、運転免許証やパスポート、マイナンバーカードなどに格納されている顔写真データ(JPEG 2000形式など)を読み込む際、従来のJNI(Java Native Interface)経由の直接実行では、悪意のある細工された画像データによって深刻な脆弱性を突かれ、アプリ全体が危険にさらされるリスクがありました。

この課題に対する実践的な解決策として、講演者は Wasm と Jetpack JavaScript Engine を組み合わせた多層防御(Defense-in-Depth)を提案しています。Wasmによってシステムコールを持たないメモリ隔離環境(第一層)を構築し、さらにJS Engineによってネットワークやローカルファイルへのアクセス権限を持たない別プロセス(第二層)として実行します。これにより、万が一デコーダーの脆弱性を突かれても、被害をサンドボックス内に完全に封じ込め、アプリ本体への影響を防ぐことが可能になります。

また、実装上の大きな障壁となるプロセス間のデータ転送コストについても詳細な検証が行われています。文字列変換によるデータ受け渡しでは、プラットフォーム側にネイティブ実装が存在する Base64 を使用するのが最もパフォーマンスが高いことが実証されました。しかし現在では、JS Engine バージョン1.1.0で導入されたMessagePort APIを活用することで、バイナリデータの双方向通信が可能となり、エンコードのオーバーヘッドが劇的に解消されることが解説されています。あわせて、プロセス間通信の1MB容量制限も、RAM 上のファイルディスクリプターを介することで安全に回避できる点が示されています。

結論として、Wasm はメモリコピーが発生する点(ゼロコピー不可)やコードの隠蔽化に向かない点においてJNIとトレードオフの関係にあります。しかし、外部からの信頼できないデータを処理する要件においては、過去の優れたネイティブ資産を極めて安全にモバイル環境へ持ち込むための、非常に有効なベストプラクティスであると位置づけています。

まとめ

今年は例年と違い、 AI 関連のトピックが増加した印象です!

ブースでは AI を用いた開発に関してのアンケートが多数見受けられました!

セッションでは デバイス操作はAIエージェントの時代へ。mobile-mcpを活用したAndroid UI/E2Eテストの挑戦AI に Inclusive UI を書かせよう — Design Rules Skill で Compose UI を作り直す のような AI を開発効率化に用いる内容から、Google のオープンモデル Gemma を活用した最新の AI 開発のトレンド のような AI 開発そのものについてまで幅広く講演されており、時代の変化を感じました!

またブースには本当に多くの方に足を運んでいただき、たくさんの人にエブリーを知っていただけて、とても良い機会でした!

これからもデリッシュキッチン、エブリーのことをよろしくお願いいたします!

最後に

エブリーでは、ともに働く仲間を募集しています。

テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください!

corp.every.tv

さらに、DroidKaigi & iOSDC After Talks Night 2026を、ゆめみ、フェンリル、Yappli、WealthNavi、セーフィー、エブリーの6社合同で開催いたします!なお、今回はiOSDC Japan 2026のアフターパーティーも兼ねているので、AndroidエンジニアだけでなくiOSエンジニアの方も交えて、プラットフォームの垣根を越えた活発な技術交流や情報交換をお楽しみいただけます。両カンファレンスの熱気をそのままに、各社によるLTセッションや懇親会をご用意しております。

項目 詳細情報
開催日時 2026年10月2日(金) 19:00 ~ 21:00
開催場所 東京都港区三田一丁目4番1号 住友不動産麻布十番ビル
開催形態 オフライン / オンライン
コンテンツ 各社のAndroid & iOSに関するセッション / 懇親会

詳細や参加登録につきましては、以下のリンクよりご確認ください。

yumemi.connpass.com

最後までお読みいただき、ありがとうございました!

DroidKaigi 2026 にゴールドスポンサーとして協賛します!

はじめに

株式会社エブリーは、2026年9月に開催されるDroidKaigi 2026にゴールドスポンサーとして協賛いたします。「エンジニアが主役のAndroidカンファレンス」である本イベントは、2026年9月1日(火)から3日(木)にかけてベルサール渋谷ガーデンで開催されます。

項目 詳細情報
イベント名称 DroidKaigi 2026
開催日程 2026年9月1日(火)〜 9月3日(木)
会場 ベルサール渋谷ガーデン(東京都渋谷区南平台町)
スポンサーランク ゴールドスポンサー
ブース出展日程 2026年9月2日(水)〜 9月3日(木)の2日間

エブリーはこれまでも、Go Conference 2025におけるPlatinum "Go"ld スポンサーや、TSKaigi 2026におけるゴールドスポンサーとしての参加など、技術コミュニティを積極的に応援してきました。今回のDroidKaigiへの協賛も、自社の開発現場で得られた知見をコミュニティに還元し、エンジニアの皆様と共に成長していくための大切な取り組みの一環です。

tech.every.tv

tech.every.tv

ブース出展:「AI時代!どこまで越境したいですか?」

9月2日および3日に出展するエブリーのブースでは、「AI時代!どこまで越境したいですか?」をテーマにした参加型のアンケートボードを設置します。Android開発をベースにしつつ、バックエンドやPdM、データサイエンスといった他の領域へどのようにスキルを広げていきたいか、皆様のリアルな声をお聞かせください。

また、エブリーの公式X(旧Twitter)をフォローしていただいた方には、ハズレなしのくじ引きをご用意しています。お鍋や計量スプーン、まな板など、デリッシュキッチンならではの実用的なキッチングッズをプレゼントしますので、ぜひお立ち寄りください。

TSkaigiでもお配りした景品例

事後レポートを公開予定です!

イベント終了後の9月3日(木)には、every Tech Blogにて最速事後レポートを公開する予定です。開発部メンバーによるセッションの感想や、アンケートボード「AI時代の越境」の集計結果など、現場のリアルな熱量をお届けします。過去のイベント協賛時と同様に、オフラインで得られた知見をいち早くコミュニティに共有していきます。

アフターパーティーも開催します!

さらに、DroidKaigi & iOSDC After Talks Night 2026を、ゆめみ、フェンリル、Yappli、WealthNavi、セーフィー、エブリーの6社合同で開催いたします!なお、今回はiOSDC Japan 2026のアフターパーティーも兼ねているので、AndroidエンジニアだけでなくiOSエンジニアの方も交えて、プラットフォームの垣根を越えた活発な技術交流や情報交換をお楽しみいただけます。両カンファレンスの熱気をそのままに、各社によるLTセッションや懇親会をご用意しております。

項目 詳細情報
開催日時 2026年10月2日(金) 19:00 ~ 21:00
開催場所 東京都港区三田一丁目4番1号 住友不動産麻布十番ビル
開催形態 オフライン / オンライン
コンテンツ 各社のAndroid & iOSに関するセッション / 懇親会

詳細や参加登録につきましては、以下のリンクよりご確認ください。

yumemi.connpass.com

おわりに

株式会社エブリーでは、技術コミュニティの発展を応援するとともに、開発現場で得た知見や知恵を共有し合う文化を大切にしています。今回の DroidKaigi 2026 への協賛を通じて、多くのエンジニアの皆様と技術やキャリアに関するお話ができることを楽しみにしています。

当日のブースでは、デリッシュキッチンをはじめとするプロダクト開発のリアルな話や技術スタック、AI時代におけるエンジニアの挑戦に関する雑談・ご質問も大歓迎です。「ちょっとノベルティのくじ引きをしてみたい」「エンジニアと軽く話してみたい」といった軽い気持ちで構いませんので、ぜひ気軽にエブリーのブースへ足をお運びください。

DroidKaigi 2026 の会場、そして10月のアフターパーティーで、皆様とお会いできることをチーム一同、心より楽しみにお待ちしております!

画面を離れても回答が完成するAIチャットを作った話

はじめに

開発本部 開発1部のいくまるです。

私たちのチームでは、Web アプリの新機能として、チャット形式でデータを分析できる AI エージェントを開発中です。開発を進める中で、「回答の生成中に画面を離れると、その回答を受け取れなくなり、会話も残らない」という課題に向き合うことになりました。

本記事では、この課題を解決するために行った「会話履歴の永続化」と「バックグラウンド実行」の設計と実装を紹介します。DB スキーマ・実装コード・検討して捨てた案まで含めて書きます。

前提:エージェントの構成

このエージェントは次の構成で動いています。

ブラウザ(チャット UI)
    │  AG-UI イベント(SSE)
    ▼
Next.js(API Route ── ブラウザと AgentCore の中継役)
    │  InvokeAgentRuntime(SSE)
    ▼
Amazon Bedrock AgentCore Runtime(Strands Agents 製エージェント)
    │  MCP
    ▼
MCP サーバー(自社データの検索・集計ツール群)
  • Amazon Bedrock AgentCore: AI エージェントの実行基盤となる AWS のサービスです。セッションごとに microVM 単位で実行環境が分離されます。
  • Strands Agents: AWS が公開しているオープンソースの AI エージェント SDK です。
  • AG-UI: エージェントとフロントエンド間のイベントストリーミングのプロトコルです。RUN_STARTEDTEXT_MESSAGE_CONTENTTOOL_CALL_*RUN_FINISHED などのイベント型を定めています。転送方法は SSE に限定されませんが、このアプリでは SSE で流しています。

ユーザーが質問を送ると、エージェントが MCP ツールでデータを取得・分析し、回答をストリーミングで返します。ツールを繰り返し呼ぶため、1 回の回答に数十秒かかることがあります。

課題:画面を離れると回答が消える

AI チャットで広く使われるのは「POST + ストリーミング応答」の構成です。ブラウザが質問を POST し、サーバーが生成イベントを流し、画面に逐次表示します。画面離脱を想定しなければ、これで十分に機能します。私たちの初期実装もこの構成でした。

私たちの場合は回答に数十秒かかるため、「生成中に画面を離れても実行は完走してほしい」という要件が加わりました。この要件を満たそうとすると、3 箇所が問題になります。

改修前の構造。画面遷移した瞬間に、以降のイベントを受け取る手段がなくなる

1 つ目はフロントエンドです。 この構成では、チャット画面の hook が unmount 時に実行を中断(abortRun())する作りになりがちです。画面遷移がそのまま実行中断になります。

// チャット画面の hook(unmount 時に実行を中断する作り)
useEffect(
  () => () => {
    stopRequestedRef.current = true;
    agentRef.current?.abortRun();
  },
  [],
);

2 つ目は中継役の API Route です。 ブラウザと AgentCore の間で SSE を中継する Next.js の API Route は、クライアントの切断を上流の AgentCore への読み取りキャンセルとして伝播します。ReadableStreamcancel() は「クライアントがもう読まない」ときに呼ばれるコールバックで、そこで上流の読み取りも止めると、切断とキャンセルの区別がなくなります。

// 中継処理(クライアントが切れると上流の読み取りも止まる作り)
const readable = new ReadableStream({
  async start(controller) {
    // 上流(AgentCore)の SSE を読み、そのままクライアントへ中継する
  },
  cancel() {
    reader.cancel();
  },
});

3 つ目は保存先です。 イベントは中継されるだけで、どこにも保存されません。仮に 1 つ目と 2 つ目を直して実行が完走するようにしても、戻ってきた画面に表示するデータがありません。

「実行状態を React のグローバルストアに持てば、画面遷移に耐えられるのでは」という案も検討しました。しかしこのアプリでは、チャット画面から他の画面への遷移が window.location.href によるページ全体の再読み込みで実装されています。再読み込み後のページは JavaScript の実行環境ごと新しく作られるため、React の state やグローバルストアに入れた値は引き継がれません。

設計方針:実行の作成と購読を分離する

大きく変えたのは次の 3 つです。

  1. 作成と購読の分離: POST /runs は実行を開始して 202 { runId } を即座に返します。表示は GET /runs/{runId}/events の SSE で受け取ります。この「SSE を受信し続けること」を、本記事では「購読」と呼びます。購読はいつ切れてもよく、何度でも再開できます。
  2. 実行ワーカーの独立: AgentCore の SSE を最後まで読み切って記録する処理(実行ワーカー)を、HTTP レスポンスから独立した非同期タスクにしました。ブラウザが切断しても実行は完走します。
  3. 二層の保存: 実行中は「生成途中の回答」を DB に上書き保存し続け、完了したら完成したメッセージを DB の履歴テーブルに保存します。テーブル構成は次の節で説明します。

なお、実行ワーカーは Next.js と同じプロセス内で動かしているため、リクエストごとに実行環境が終了するサーバーレス環境ではこの形は取れません。現在は検証段階のためこの構成にしていますが、Next.js のデプロイに走行中の実行が巻き込まれないようにするため、本来は実行ワーカーを独立したプロセスに切り出す方が望ましいと考えています。現状、プロセスがデプロイなどで止まる場合の後始末は、同時実行制御の節で説明する回収の仕組みが担います。

改修後の構造。実行は接続と無関係に完走し、購読は何度でも再入場できる

ブラウザとサーバーの間の API は次の 5 本です。

API 役割
POST /runs 実行を作成して 202 { runId, conversationId } を即返す
GET /runs/{id}/events SSE 購読。切断・再入場が自由
POST /runs/{id}/cancel 明示的なキャンセル
GET /conversations 会話一覧(履歴サイドバー用)
GET /conversations/{id} 会話の全メッセージ + 実行中の run(あれば)

最後の API がポイントです。リロードや別タブで会話を開いた直後、クライアントは会話 ID しか知らず、実行中の run があるかどうかも分かりません。そこで GET /conversations/{id} は、会話のメッセージに加えて「実行中の run の ID」を返します。クライアントはその ID で GET /runs/{id}/events を購読し、生成途中から表示を再開します。

DB スキーマ:残す履歴と実行中の状態を分ける

このアプリでは以前から、本体機能のデータを Aurora MySQL 8.0 + Prisma で管理しています。エージェントの履歴も同じ DB に、3 つのテーブルで持つことにしました。ずっと残す「履歴」と、実行中だけ使う「実行状態」でテーブルを分けています。

区分 テーブル 役割 行の扱い
履歴 conversations 会話スレッド 1 件のメタ情報 ずっと残す
履歴 conversation_messages メッセージ 1 件 = 1 行。完成した発話を保存 ずっと残す
実行状態 runs 実行 1 回の状態 + 生成途中の回答 + ロック 行は実行 1 回ごとに増え、終了後も記録として残る。生成途中の回答やロックは実行中だけ使う

ER 図

Prisma スキーマは次の通りです。実際に採用したものから、タイムスタンプ列・リレーション定義・enum 定義(RunStatus / MessageStatus)を省いています。

model Conversation {
  id        String    @id @default(cuid())
  companyId Int       @map("company_id")
  userId    String    @map("user_id")
  threadId  String    @map("thread_id") @db.Char(36)
  title     String    @db.VarChar(255)
  deletedAt DateTime? @map("deleted_at")

  @@unique([companyId, userId, threadId])
  @@map("conversations")
}

model ConversationMessage {
  id             String        @id @default(cuid())
  conversationId String        @map("conversation_id")
  runId          String?       @map("run_id")
  sequence       Int
  role           String        @db.VarChar(16)
  parts          Json
  status         MessageStatus @default(complete)

  @@unique([conversationId, sequence])
  @@map("conversation_messages")
}

model Run {
  id              String    @id @default(cuid())
  conversationId  String    @map("conversation_id")
  clientTurnId    String    @map("client_turn_id") @db.VarChar(64)
  status          RunStatus @default(queued)
  lockKey         String?   @unique @map("lock_key")
  ownerInstanceId String?   @map("owner_instance_id") @db.VarChar(64)
  errorCode       String?   @map("error_code") @db.VarChar(64)
  partialState    Json?     @map("partial_state")
  heartbeatAt     DateTime  @default(now()) @map("heartbeat_at")

  @@unique([conversationId, clientTurnId])
  @@index([status, heartbeatAt])
  @@map("runs")
}

lockKey の UNIQUE、clientTurnId の複合ユニーク、[status, heartbeatAt] のインデックスがそれぞれ何のためにあるかは、後の節で順に説明します。

また、本記事には 4 種類の ID が登場します。ここで整理しておきます。

ID 何を指すか
conversationId DB 上の会話。API で会話を指すときに使う
threadId AG-UI 上の会話 ID。DB 上の会話(conversationId)と 1 対 1 で対応
runId 質問 1 回ぶんの実行
runtimeSessionId AgentCore の実行環境を束ねる ID。t{companyId}-u{userId}-{threadId} の形式で、会話ごとに固定

保存単位は「AG-UI の Message 1 件 = 1 行」

conversation_messages は追記専用で、AG-UI の Message をそのまま parts(JSON)に格納します。テキストだけのターンは user / assistant の 2 行、ツールを使うターンは assistant(ツール呼び出し)と tool(結果)の行が挟まって 4 行以上になります。

1 会話のメッセージ行の例。ツールを使うターンは user・assistant(ツール呼び出し)・tool・assistant の 4 行、使わないターンは 2 行になる

この保存単位を選んだ理由は、フロントの表示ロジックの作りにあります。ライブ表示は「AG-UI の Message 配列を受け取り、ターンの区切りやツールの実行ステップ表示を組み立てる純粋関数」として自前で実装しています。保存した Message 列をそのままこの関数に渡せば、画面を離れなかった場合と同一の表示が再現されます。保存時に表示用の形へ加工してしまうと、同じ表示を再現できなくなります。

DynamoDB ではなく Aurora MySQL を選んだ理由

会話履歴のアクセスパターン(会話 ID + 連番で順に全件取得、追記専用、JSON 主体)は DynamoDB の得意領域で、実際に移行案も検討しました。それでも Aurora MySQL 一本にしています。

まず、トランザクション要件が構成の選択肢を絞ります。質問の受付時には「会話 + user メッセージ + 実行(ロック)の INSERT」を、完了時には「assistant メッセージの INSERT + 実行の完了 + ロック解放」を、それぞれ単一トランザクションで行う必要があります。「履歴は DynamoDB、実行状態は Aurora」のように 2 つのストアに分けると、この原子性を保証できません。原子性が無いと、たとえば次のような壊れ方をします。

  • 回答は残ったのに次の質問ができない: 完了処理の「回答を保存」と「ロック解放」の間でプロセスが落ちると、画面には回答が出ているのに DB はロックを握ったままになり、次の質問が「実行中です」と拒否され続けます。
  • 答えのない質問が履歴に残る: 送信の二度押しで 2 本目が「質問を保存 → ロックで弾かれる」の順に進むと、誰も回答しない質問だけが履歴に残ります。1 トランザクションならロック取得の失敗と同時に質問の保存も取り消され、エラー応答だけを返せます。

したがって選択肢は「全部 Aurora」か「全部 DynamoDB」に絞られます。後者も技術的には成立します(DynamoDB でも TransactWriteItems で複数の項目をまとめて原子的に書けます)。

それでも Aurora にしたのは、既存の運用との一貫性のためです。このアプリの他のデータはすべて Aurora + Prisma で管理していて、マイグレーションの手順やレビューの観点といったチームの運用もそこで揃っています。データストアを 2 つにすると、この運用も 2 系統になります。規模の面でも、DB への書き込みはピークでも毎秒数十回の見積もりで、Aurora で十分に受けられます。DynamoDB のスケール性能が必要になる水準ではありません。

会話履歴はサーバーが組み立てる

エージェントは毎回の呼び出しで会話の全履歴を受け取り、状態をゼロから組み立て直す作りにしています。この全履歴を誰が用意するかには 2 つの形があります。クライアントが手元の Message 配列を毎回送るか、サーバーが DB から組み立てるかです。私たちは後者にしました。クライアントが送るのは新しいメッセージ 1 件だけです。

前者を避けた理由は、バックグラウンド実行と相性が悪いからです。実行を放置して別のタブで完走させると、元のタブが持っている履歴は古いままになります。その古いタブから次の質問を履歴ごと送ると、完走したはずの回答がモデルへの入力から抜け落ち、会話のつじつまが合わなくなります。後述するロックは実行中しか効かないため、この事故は防げません。最新の会話を常に持っているのは DB だけです。

なお、AgentCore 側に会話の状態を持たせる案も 2 つ検討し、見送りました。

  • 実行環境(microVM)のメモリに持つ: セッション ID は会話ごとに固定なので、同じ会話の呼び出しは同じ実行環境に届き、メモリに状態を残すこと自体はできます。ただしこの環境は無操作 15 分(デフォルト)などで終了し、メモリごと消えます。時間を空けて続く会話の置き場にはできません
  • Memory サービスに持つ: AgentCore には会話を保存する Memory というサービスもあります。ただし履歴はどのみち表示のために自前の DB へ保存するので、足すと同じ役割の保存先が 2 つになります

POST /runs のボディは { conversationId, message, clientTurnId } だけです。モデルへ渡す履歴は、実行ワーカーが Aurora から組み立てます。

// 実行ワーカーの一部:DB から会話履歴を読み出し、モデル入力用に整える
export async function buildModelMessages(conversationId: string): Promise<ModelMessagesResult> {
  const rows = await prisma.conversationMessage.findMany({
    where: { conversationId, status: "complete" },
    orderBy: { sequence: "asc" },
    select: { parts: true },
  });
  return normalizeHistory(rows);
}
// 実行ワーカーの一部:組み立てた履歴をエージェントに渡して生成を開始する
const history = await buildModelMessages(claimed.conversationId);

const { companyId, userId, threadId } = claimed.conversation;
const agent = new AgentCoreAgent({
  threadId,
  initialMessages: history.messages,
  agentArn,
  runtimeUserId: `c${companyId}:u${userId}`,
  runtimeSessionId: `t${companyId}-u${userId}-${threadId}`,
});

こうすると、会話の内容はサーバー(DB)だけが持つ構造になります。AgentCore の microVM がタイムアウトで終了しても、クライアントがリロードで状態を失っても、会話は Aurora から再構成できます。

書き込み設計:イベントは保存せず「生成途中の回答」を上書きする

1 回の回答で AG-UI イベントは数十〜数百個流れます。本文の断片(デルタ)1 つ 1 つやツール呼び出しがそれぞれイベントになるため、回答が長いほど増えます。これを 1 行ずつ INSERT すると、1 回答ごとに大量の行が永久に積み上がります。採用したのは次の形です。

時点 DB 操作 内容
質問送信 INSERT conversations に会話(新規会話のときのみ)、conversation_messages に質問、runs に実行レコード(ロック取得を兼ねる)の最大 3 行
生成中 runs.partial_state を上書き UPDATE AG-UI のイベントでは本文が細切れ(デルタ)で届く。実行ワーカーはそれをつなぎ合わせた「その時点のメッセージ配列」を保持しており、これを数秒ごとに同じ 1 行へ上書き保存。行は増えない
完了 conversation_messages に INSERT そのターンで生まれたメッセージ(回答、ツールを使った場合はその呼び出しと結果も)を保存。runs.partial_state を空にし、ロックを解放

質問送信時のトランザクションは以下のようになっています。ロック(lockKey)の取得と質問の保存が、同時に成立するか同時に失敗するかのどちらかになります。

// API Route の一部:質問受付時の書き込み
return prisma.$transaction(async (tx) => {
  const conversationId = existingId ?? (await tx.conversation.create({ /* 省略 */ })).id;

  // ロック取得に失敗したら、下で保存する質問ごと取り消される(同一トランザクションのため)
  const run = await tx.run.create({
    data: { conversationId, clientTurnId, lockKey: conversationId },
    select: { id: true },
  });

  // aggregate(集計クエリ)で会話内の最大 sequence を取り、次の連番を振る
  const highest = await tx.conversationMessage.aggregate({
    where: { conversationId },
    _max: { sequence: true },
  });

  await tx.conversationMessage.create({
    data: {
      conversationId,
      runId: run.id,
      sequence: (highest._max.sequence ?? 0) + 1,
      role: "user",
      parts: { id: randomUUID(), role: "user", content: message },
    },
    select: { id: true },
  });

  return { runId: run.id, conversationId };
});

生成中の partial_state は 3 秒間隔で間引いて書きます。間引きに加えて「前の UPDATE が完了するまで、次の UPDATE を発行しない」という制御も入れています。UPDATE を発行した順と DB に反映される順は一致するとは限らないため、古い内容の UPDATE が新しい内容の後に適用されると、保存済みの「生成途中の回答」が巻き戻ってしまうからです。

// 実行ワーカーの一部:生成途中の回答を数秒ごとに DB へ上書き保存する
const PARTIAL_STATE_INTERVAL_MS = 3_000;

return {
  schedule() {
    // 前の書き込みが完了するまで次をスケジュールしない(古い内容への巻き戻りを防ぐ)
    if (timer || writing || truncated) return;
    timer = setTimeout(() => {
      timer = null;
      writing = true;
      void writePartialState(runId, ownerInstanceId, produced())
        .then((state) => {
          truncated = state.truncated;
        })
        .catch((error: unknown) =>
          console.error("partial_state の更新に失敗しました", { runId, error }),
        )
        .finally(() => {
          writing = false;
        });
    }, PARTIAL_STATE_INTERVAL_MS);
  },
};

数秒おきに UPDATE を発行し続けて DB の負荷は大丈夫なのか、という点は検討しました。結論としては、同時に走る生成が多くても数十本という規模では問題になりません。更新は各実行が自分の 1 行だけを主キー指定で行い、実行間のロック競合はありません。さらに、接続中のユーザーの画面へは実行ワーカーがメモリ上のイベントを直接流すため、partial_state の用途は後述する再合流だけです。毎秒書く必要も、イベントを 1 個ずつ書く必要もありません。

完了時は「回答の確定保存」「実行ステータスの完了への更新」「ロック解放」「生成途中の回答(partial_state)の削除」を 1 トランザクションで行います。

// 実行ワーカーの一部:完了時の書き込み。lock_key を外し損ねるとその会話が永久に 409 になる
return prisma.$transaction(async (tx) => {
  const claimed = await tx.run.updateMany({
    where: terminableWhere(runId, ownerInstanceId),
    data: {
      status,
      errorCode,
      finishedAt: new Date(),
      lockKey: null,
      partialState: Prisma.DbNull,
    },
  });

  // 別の経路(キャンセルや、後述する異常終了時の回収処理)が先にこの実行を
  // 終わらせていたら、生成物は保存しない
  if (claimed.count === 0) return false;

  await tx.conversationMessage.createMany({
    data: messages.map((message, index) => ({
      conversationId: targetId,
      runId,
      sequence: base + index + 1,
      role: message.role,
      parts: message as Prisma.InputJsonValue,
      status: messageStatusAt(status, message.id, openMessageIds),
    })),
  });

  return true;
});

メッセージの status は通常 complete で保存します。キャンセルやエラーで実行が正常に終わらなかった場合は、そこまでに生成できていた分を partial(部分的、の意味)として保存し、画面に残せるようにしています。

再合流:会話全体のスナップショットで追いつく

実行中の会話に購読者が入ってくると、サーバーはまず RUN_STARTED(「実行が進行中です」の合図)と MESSAGES_SNAPSHOT を送ります。どちらも上流から届いたイベントの中継ではなく、この購読のためにサーバーが新しく作って送るものです。MESSAGES_SNAPSHOT を受け取ったクライアントは、手元のメッセージ一覧を捨てて、スナップショットの内容で丸ごと置き換えます。そのため、スナップショットに生成途中の 1 件だけを入れると、過去のメッセージが画面からすべて消えてしまいます。必ず会話の全メッセージを入れて送ります。

その後の配信は 2 つのモードに分かれます。分かれ目は「購読がいつ始まったか」です。

モード いつ使われるか 配信内容
live 質問の送信直後から購読している場合(生成イベントがまだ 1 件も流れていないうちに購読が始まったとき) 実行ワーカーが受け取る生成イベント(TEXT_MESSAGE_CONTENT など)を、メモリからそのまま逐次中継
poll それ以外すべて(リロード・別タブ・離脱して戻ってきた場合) 1 秒間隔で DB を読み、確定済み履歴と partial_state の生成途中回答をマージした会話全体の MESSAGES_SNAPSHOT を、内容が変わったときだけ送り直す。実行が終わったら RUN_FINISHED(または RUN_ERROR)で締める

つまり、途中から戻ってきた購読者が受け取るのは live 配信のイベント列ではなく、「会話全体のスナップショットの送り直し」です。

// 配信モードの選択。イベントが 1 件でも流れた後に始まった購読は poll に回す
export function attach(runId: string, signal: AbortSignal): LiveSubscription | null {
  const fanout = fanouts.get(runId);
  if (!fanout) return pollInstead(runId, "fanout_absent");
  if (fanout.closed) return pollInstead(runId, "fanout_closed");
  // 1 件でも中継済みなら列の途中からになるので、履歴を出せる poll に任せる
  if (fanout.relayed > 0) return pollInstead(runId, "already_relayed");
  // 省略(購読者を登録し、生成イベントを流す AsyncGenerator を返す)
}
// poll 配信のループ。会話全体のスナップショットを、内容が変わったときだけ送り直す
while (true) {
  const event = snapshot(stored, readPartialMessages(progress.partialState));
  const serialized = JSON.stringify(event);
  if (serialized !== previous) {
    previous = serialized;
    yield event;
  }
  if (isTerminal(progress.status)) break;

  await sleep(POLL_INTERVAL_MS, signal);
  progress = await readProgress(run.id);
  if (isTerminal(progress.status)) stored = await loadConversationMessages(run.conversationId);
}
yield terminalEvent(run, progress);

途中合流の購読者を live のイベント列に合流させず poll に回すのは、正しさを優先したためです。デルタの続きから流すには、「スナップショットに含めた分」と「これから流すデルタ」の境界を厳密に合わせる必要があります。境界がずれると、content += delta の積み上げで本文が二重に連結されます。会話全体のスナップショットを送り直す形なら、毎回が丸ごとの置き換えなので、この事故が原理的に起きません。その代わり、poll 配信の画面は live 配信のようなストリーミング表示にはならず、数秒おきに文章がまとまって進む表示になります。途中合流でもストリーミング表示にすることは、後続の課題にしています。

同時実行制御:実行中ロックを NULL 可のユニーク列で作る

「同一会話に実行中の run は 1 本だけ」を DB で強制します。PostgreSQL なら部分インデックス(partial index。CREATE UNIQUE INDEX ... WHERE status IN ('queued','running') のように、条件を満たす行だけへ一意制約をかけられます)で書けますが、MySQL 8.0 には相当する構文が用意されていません。

代わりに runs.lock_key(NULL 可・UNIQUE)を使いました。実行中は lock_key = conversationId、終了時に NULL へ戻します。MySQL のユニークインデックスは NULL を重複として扱わないため、終了済みの run は何本でも共存でき、実行中は会話ごとに 1 本に絞られます。

同一会話への 2 本目の POST /runs は、INSERT 時にユニーク制約違反のエラーとして原子的に弾かれます。重複には 2 種類あります。1 つは「同じ送信の二度押し」です。クライアントは送信 1 回ごとに ID(client_turn_id)を発行し、リトライでも同じ ID を送るため、この列の重複で検出できます。送信ボタンの連打はフロントでも抑止できますが、ネットワーク不調時の自動再送などフロントの制御では防げない経路が残るため、DB でも守ります。もう 1 つは「別の質問の並行送信」(lock_key の重複)で、同じ会話を複数のタブで開いているときに起きます。どちらだったかを引き直して、応答を分岐します。

// API Route の一部:重複キーエラーの解釈
} catch (error) {
  if (!isUniqueViolation(error)) throw error;

  // 二度押しなら、先行の run をそのまま返す(同じ送信は 1 回として扱う)
  const raced = await findRunByClientTurnId(params);
  if (raced) return { ok: true, runId: raced.id, conversationId: raced.conversationId };

  // 並行送信なら、実行中の run を添えて拒否へ
  const activeRunId = await findActiveRunIn(conversationId);
  if (activeRunId) return { ok: false, reason: "active_run", activeRunId };
}
// API Route の一部:並行送信への応答
if (result.reason === "active_run") {
  return Response.json(
    { errors: "この会話はいま実行中です", activeRunId: result.activeRunId },
    { status: 409 },
  );
}

409 のレスポンスに activeRunId を含めているのは、UI がそれを使って「拒否」ではなく「実行中の run への購読切り替え」に変換できるようにするためです。

ロックの解放漏れに備える

ロックには解放漏れへの備えも必要です。サーバーのプロセスが突然落ちると、running のままロックを握った run が残ります。そうなると、誰も実行していないのにその会話への質問が「実行中です」と拒否され続けます。備えは 2 つの仕組みの組み合わせです。

  • 実行ワーカーは、実行中の run の heartbeat_at を定期的に現在時刻へ更新します(処理が続いていることの記録です)
  • それとは別の掃除処理が、heartbeat_at の更新が一定時間止まっている queued / running の run を「担当プロセスが異常終了した」とみなして failed にし、ロックを解放します。heartbeat_at は INSERT 時に現在時刻が入るため、202 を返した直後・実行が始まる前にプロセスが落ちて queued のまま残った run も、この経路で回収されます(スキーマの @@index([status, heartbeatAt]) はこの検索用です)

「サーバー起動時に、残っている running を全部 failed にする」というより単純な方法は採れませんでした。デプロイ中は新旧のサーバーがしばらく同時に動いており、旧サーバーがまだ実行している最中の run を、新サーバーの起動処理が誤って failed にしてしまうためです。run に owner_instance_id(どのサーバーがその実行を担当しているか)を持たせているのも同じ理由です。掃除処理は、自分のサーバーがいま実行している run を誤って回収しないよう、この ID とメモリ上の実行一覧を突き合わせて判定します。

この回収の仕組みは、デプロイやスケールインでプロセスごと止められた場合の後始末も兼ねています。止まったプロセスが抱えていた実行は途中から再開できませんが、heartbeat が途絶えるため数分以内に failed になり、そこまでの生成分は partial として履歴に残り、会話のロックも解放されます。ユーザーは失敗を確認して、すぐ次の質問に進めます。

停止:切断とキャンセルを区別する

この設計では、タブを閉じる・画面を遷移するのは「切断」であり、実行は継続します。明示的に止めたいときは POST /runs/{id}/cancel を呼びます。実行ワーカーにキャンセル要求の印を立てて上流への購読を切り離し、実行を「キャンセル」として記録します。記録後に遅れて届いた生成物は、前述の完了時トランザクションの「別の経路が先に終わらせていたら保存しない」分岐で破棄されます。

// API Route の一部:キャンセル処理
if (getActiveRun(runId) || run.ownerInstanceId === OWNER_INSTANCE_ID) {
  // 終了の記録は実行ワーカーに任せる。ここで書くと、ワーカーが「先に終了済み」と判定して生成物を捨てる
  const active = registerRun(runId);
  active.cancelRequested = true;
  await active.agent?.detachActiveRun();
  return { ok: true };
}

// 別のサーバーが担当している run。会話のロックを解放するために記録だけ書く
await finalizeRun({ runId, status: "cancelled", finalizedBy: `cancel:${OWNER_INSTANCE_ID}` });

実行中の会話への追加送信は、前述のロックにより 409 で拒否されます。ただし 409 を返すだけだと、「戻ってきたら画面が止まって見える → もう一度送る → エラー」という流れになりやすいため、途中経過の可視化(再合流)を初回リリースの範囲に含めています。実行中であることが見えていれば追加送信は起きにくく、方向を変えたい場合も「停止してから送る」導線に誘導できます。

Redis は要るか

同種の設計では、実行中イベントの共有に Redis(Redis Streams)を使う構成がよく知られています。ただし Redis が必要になるのは「タスクを 2 つ以上に増やし、かつストリーミング表示を保ちたい」場合です。今回はどちらにも当てはまらないため、入れていません。

現在は ECS 1 タスクで動かしており、通常時は再接続のリクエストが実行ワーカーと同じプロセスに届きます。イベントはプロセス内のメモリで手渡せるため、Redis なしで live 配信が成立します。

タスクを 2 つ以上に増やすと、購読のリクエストが実行ワーカーのいない方のタスクへ届くことがあります。live 配信はワーカーと同じプロセスのメモリを介して成り立っているため、別のタスクに届いた購読では使えません。ただし DB はどのタスクからも読めるので、poll 配信はそのまま動きます。つまり live 配信できたはずの購読が poll 配信になり、ストリーミング表示が数秒おきの更新になるだけで、履歴も途中経過も見られます。設計方針の節で触れた「実行ワーカーを独立したプロセスに切り出す」場合も、ワーカーと購読者が必ず別プロセスになるため、同じく中継が必要になります。当面は 1 タスクで足りる規模のため、現時点ではこの構成にしています。

まとめ

  1. 実行の作成と購読を分離し、実行ワーカーを HTTP 接続から独立させることで、画面を離れても実行が完走する構造にしました。
  2. 履歴は「メッセージ 1 件 = 1 行」でずっと残し、生成途中の回答は上書き更新の 1 行に分けました。イベントの逐次保存はせず、モデルへ渡す履歴もサーバーが DB から組み立てます。
  3. 同時実行制御は MySQL の「NULL 可ユニーク列」によるロックで実現しました。キャンセルは購読の切り離しと、実行を「キャンセル」として記録することで実現し、切断とは明確に区別しています。

AI チャットの「履歴」と「バックグラウンド実行」は別々の機能に見えますが、作ってみると、どちらも「会話の状態はサーバー側で持つ」という同じ設計に行き着きました。AI チャットの実行基盤を作る際に共通して現れる論点だと思うので、同じものを作る方の参考になれば幸いです。

Next.js 16.3のPartial Prefetchingをプロダクトで検証してみる

目次

はじめに

こんにちは、開発本部の黒髙です。最近はアメリカ向けレシピサービスであるOISHYの開発に関わっています。

Next.js 16.3では、ページ遷移の応答性を改善する仕組みとしてInstant Navigationsが追加されました。クリック前に再利用できるUIやデータを準備し、クリック後に必要な部分を取得することで、遷移直後から次のページを描画しやすくする仕組みです。

Instant Navigationsを構成する機能のうち、今回取り上げるのはPartial Prefetchingです。本記事では、Partial Prefetchingを紹介したうえで、OISHYの既存ページでは通信と画面遷移にどのように現れるのかを検証します。

Partial Prefetchingは何を解決するのか

すべてを先読みする方法と、何も先読みしない方法の中間を作る

Next.jsの<Link>によるprefetch(先読み)では、リンクが画面内に入ると、ユーザーがクリックする前に遷移先のデータを取得します。クリック時にデータが揃っていれば、遷移先をすぐに表示できます。

一方、リンクが多い画面では、実際にはクリックされないリンクのデータも取得します。先読みを無効にすれば事前通信はなくなりますが、その場合はクリックしてから遷移先のデータを取得することになります。

Next.js 16.3のPartial Prefetchingは、この二つの中間を作る機能です。Cache Componentsを使うページへの既定の<Link>では、URLごとの完成形をすべて先読みする代わりに、同じ種類のページで再利用できるApp Shellを先読みします。クリックされたURLに固有のデータは、必要になった時点で取得します。

App Shellとは、URL固有のデータを待たずに表示できるページの共通部分です。サイト共通のレイアウトや、データの読み込み中に表示するUIなどが含まれます。

レシピ詳細ページを例にすると、レシピ名や材料はURLごとに変わりますが、それらを待っている間の枠組みは複数のレシピで共有できます。同じルートを指す複数のリンクで一つのApp Shellを再利用できるため、リンクごとに同じ共通部分を取得し直す必要もありません。

この仕組みは、次のようなページで効果が現れやすいと考えられます。

  • 商品、記事、レシピなど、同じ種類の詳細ページへのリンクが多数並ぶ
  • 表示されたリンクのうち、実際にクリックされるのは一部だけである
  • 詳細ページに、共通レイアウトやデータ取得中の表示など、URLをまたいで再利用できる部分がある

OISHYのトップページにも、同じ/recipes/[id]へ遷移するレシピリンクが多数並びます。そこで今回は、レシピ詳細ページをPartial Prefetchingへ切り替えたとき、クリック前の通信とクリック後の表示がどう変わるかを確かめました。

OISHYでPartial Prefetchingを試す

OISHYでは、すでにCache Componentsを有効にしています。まず、レシピ詳細ページへの遷移にPartial Prefetchingを適用するため、次の設定を追加しました。

// app/recipes/[id]/page.tsx
export const prefetch = 'partial';

公式リファレンスでは、prefetch = 'partial'はリンクではなく遷移先に設定するものと説明されています。この設定により、レシピ詳細ページをPartial Prefetchingへ段階的に切り替えます。

検証条件

主な検証では、次の3条件を比較しました。

条件 Next.js Partial Prefetchingを適用する場所 データ取得中の表示
条件A 16.2.11 適用しない なし
条件B 16.3.1 レシピ詳細ページ なし
条件C 16.3.1 レシピ詳細ページ スケルトン表示

実験1では条件Aと条件Bを比較し、クリック前の取得を減らしたときに通信と画面遷移がどう変わるかを確認しました。実験2では条件Bと条件Cを比較し、URL固有のデータを待つ間にApp Shellが画面にどう現れるかを確認しました。

以下で扱う条件B・Cと補足検証の先読み通信は、Next.js 16.3.1で観測したものです。内部的な通信の形は、今後のバージョンで変わる可能性があります。

OISHYの既存構成では、Next.jsのstandalone serverをECSで動かし、その前段にCloudFrontとALBを置いています。今回の計測もこの配信経路で行いました。

計測方法

  • 対象:同じ10件のレシピを、それぞれ3回ずつ計測
  • 操作:トップページを新しいタブで開き、対象リンクが画面内に入る位置までスクロール。5秒待ってからクリックし、クリック前の先読み通信とレシピタイトルが表示されるまでの通信・時間を記録
  • ブラウザ条件:画面サイズは1280×900、回線速度の制限はなし。ブラウザ側のHTTPキャッシュは計測のたびに無効化し、スクロール位置とクリックまでの待ち時間を統一
  • 集計:各レシピの3回の中央値を求めたあと、10件の中央値を代表値として使用。通信量にはChrome DevTools ProtocolのNetwork.loadingFinished.encodedDataLengthを使い、ブラウザが受信したデータ量に近い値を比較

実験1:クリック前の取得を減らすと、通信と遷移はどう変わるか

最初に、Next.js 16.2.11の条件Aと、16.3.1へ更新してレシピ詳細ページをPartial Prefetchingへ切り替えた条件Bを比較しました。この比較にはNext.js自体の更新も含まれるため、Partial Prefetchingだけの効果ではなく、OISHYで16.3.1への更新と機能導入を行った前後差として扱います。

ここでいうRSC(React Server Components)データは、Next.jsがクライアント側の画面を更新するために送るデータです。

指標(中央値) 条件A(変更前) 条件B(レシピ詳細ページに適用)
クリック前のRSCリクエスト 75 6
クリック前の転送量 約319KB 約20KB
クリック後のRSCリクエスト 0 1
クリック後の転送量 0KB 約20KB
クリック前後の合計リクエスト 75 7
クリック前後の合計転送量 約319KB 約38KB

クリック前のリクエスト数は約92%、転送量は約94%減りました。クリック後には、選択したレシピのRSCリクエストが1件発生しています。それを含めた合計転送量も約88%減っており、クリック前の通信がそのまますべてクリック後へ移ったわけではありませんでした。

変更前は、画面内に並ぶ多数のレシピについてURL固有のRSCデータを取得していました。変更後は、共通部分を先読みし、選択したレシピのデータをクリック後に取得しています。今回の画面では、クリックされなかったレシピへの先読みが減ったことが、通信量の差として大きく現れました。

次の画像は1回の計測例で、表の数値は反復計測から求めた代表値です。

条件Bでのクリック前のNetwork記録の例

一方、クリックからレシピタイトルが表示されるまでの代表値は、変更前が約44ms、変更後が約82msで、どちらも100ms未満でした。URL固有のデータをクリック後に取得するようになったことと整合しますが、今回の条件では目視できるほど長い待ち時間にはなりませんでした。大きく変わったのは、完成画面が出るまでの見た目よりも、クリック前に取得するデータの量でした。

なお、この結果は回線速度を制限しない環境でのものです。Partial Prefetchingは、クリック前の転送量を減らす代わりに、URL固有データの取得をクリック後へ移す仕組みです。低速な回線では、クリック後の待ち時間が今回より長くなる可能性がある一方、クリックされないリンクへの先読みを抑える効果は大きくなるため、有効に働くかどうかは通信環境によってトレードオフになり得ます。

実験2:URL固有のデータを待つ間に何が見えるか

Partial Prefetchingでは、URL固有のデータがクリック時点で揃っていなければ、クリック後にデータの取得を待つ時間が生じます。その間にApp Shellがどのように現れるかを見るため、条件Cではレシピデータの取得中に表示するスケルトンをloading.tsxとして定義しました。

loading.tsx自体はNext.js 16.3の新機能ではありません。同じルート階層のページをSuspense境界で囲み、定義したUIをデータの準備中に表示する仕組みです。Partial Prefetchingでは、この代替表示を含むApp Shellが先読みの対象になります

// app/recipes/[id]/loading.tsx
export default function RecipeDetailLoading() {
  return <RecipeDetailSkeleton />;
}

スケルトンとは、完成後のレイアウトに近い枠を先に表示し、データを待っている場所を示すUIです。今回のレシピ詳細ページでは、次のスケルトンがApp Shellとして先に表示される部分になります。

レシピ詳細ページのスケルトン表示例

30回中25回はスケルトンを経由せず、完成したレシピ詳細が直接表示されました。スケルトンが先に現れたのは5回で、クリックから14〜22msで表示され、レシピタイトルより約68〜856ms先行しました。

この結果から、スケルトンは毎回挟まる中間画面ではなく、レシピ固有のデータがクリックまでに揃わなかった場合だけ、遷移先の枠組みとして先に表示されることが分かりました。

また、条件Cの通信を追加で確認すると、条件Bとは異なる挙動が見つかりました。条件Bではレシピ固有のデータをクリック後に取得していた一方、条件Cの補完計測では、10回中4回で複数のレシピ固有データをクリック前に取得するRSC通信が並びました。

条件Cでクリック前に観測したレシピ固有データの先読み

この違いがPartial Prefetchingの適用範囲と関係するのかを確認するため、追加計測しました。

補足:適用範囲によって先読みの形が異なった

条件Cでは、レシピ詳細ページだけにprefetch = 'partial'を設定していました。

prefetch = 'partial'は、設定したページより上位のルート階層まで同じ設定にするものではありません。Next.js 16.3.1の実装では、それぞれのルート階層が、その階層で指定された設定かアプリ全体の既定値を参照します。

この挙動を調べる中で、段階導入時の先読みを扱うNext.js 16.3.1の公式テストを見つけました。このテストにも、動的なページだけをPartialにし、未設定の上位階層を従来の方法で扱う構成で、リンク先固有のデータまで先読みする例があります。

そこで、条件Cのloading.tsxとページ側の設定を残したまま、アプリ全体の既定値をPartialにするpartialPrefetching: trueだけを追加しました。

// next.config.ts
const nextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};

同じ導線を10回計測した結果は次のとおりです。

Partial Prefetchingの適用範囲 URL固有データの先読み 共有部分の先読み
レシピ詳細ページのみ 10回中4回で観測 あり
アプリ全体 10回中0回 あり

同じloading.tsxを残したまま適用範囲を変えると、今回の導線ではURL固有データの先読みが10回中4回から0回になりました。一方、App Shellを構成する共有部分の先読みは続いていました。Next.js 16.3.1の実装と公式テストを合わせると、今回観測した通信にはPartial Prefetchingの適用範囲が関係していたと考えられます。

まとめ

Partial PrefetchingをOISHYのレシピ一覧で試したところ、16.3.1への更新と機能導入後、クリック前のRSC転送量が約94%減り、クリック後を含む合計でも約88%減りました。

一方、変更前からレシピタイトルまでの表示は約44msと十分に速く、回線速度を制限しない今回の環境では、通信量ほど大きな見た目の差はありませんでした。スケルトンを含むApp Shellは、URL固有のデータがクリックまでに揃わなかった場合だけ、完成したページより先に表示されました。

また、レシピ詳細ページだけに適用した場合と、アプリ全体に適用した場合では、クリック前の先読みも異なりました。ページ単位で試せる機能ではありますが、今回の検証では適用範囲も実際の通信に関係していました。

Partial Prefetchingは、商品・記事・レシピの一覧のように、同じ種類の詳細ページへのリンクが多く、その一部だけがクリックされる画面を想定しています。クリックされないURL固有データの先読みを抑えながら、データが間に合わない場合にはApp Shellを先に表示する仕組みです。今回のOISHYでは、先読み通信量には大きな差が出た一方、見た目の差は小さいという結果でした。

参考文献

Retention Messaging でサブスクリプションの解約画面から継続利用を促せるようになるらしい

title

はじめに

こんにちは、株式会社エブリーでデリッシュキッチンのiOSアプリの開発をしている成田です。 現在は、プレミアムユーザーの登録数の向上やプレミアムユーザーの体験をより良くすることを目的としたチームで開発をしています。

サブスクリプションの解約は、これまで開発者にとってブラックボックスでした。ユーザーが App Store の管理画面で「サブスクリプションをキャンセルする」を押すとき、アプリ側にできることは何もありません。引き止めのメッセージも、オファーの提示も、そもそも解約されようとしていることを知ることさえ、その瞬間にはできませんでした。

WWDC26 で発表された Retention Messaging は、ここに初めて介入手段を与える機能です(セッション309)。解約確認画面に、アプリからのメッセージやオファーを差し込めるようになります。

Appleによれば、この機能を導入したサブスクリプションでは、解約を取りやめて利用を継続するユーザーの割合が改善されているとのことです。解約抑止にも取り組む立場としては、無視できない機能です。

まだ一般提供はされていませんが、具体的に何ができるのか、どう始めればよいのか、現時点で分かっていることを整理します。

何ができるのか

ユーザーが App Store のサブスクリプション管理から解約しようとすると、確認画面が出ます。Retention Messaging を設定しておくと、この画面にアプリからのコンテンツが表示されます。

表示できる形式は3つです。

形式 内容
メッセージのみ ローカライズ済みの引き止め文言
メッセージ + 画像 テキストメッセージに Asset Library 等から設定した画像を添えて訴求
メッセージ + オファー 割引や無料期間付きなどのオファーを提示する

ユーザーがオファーの対象である場合、オファーの表示が画像を置き換えます。「解約する前に、3ヶ月無料で続けられるオファーがあります」のような画面を、Apple の解約フローの中に出せるわけです。この画面は App Store 側が描画するもので、公式ドキュメントによれば iOS 15.1 以上で利用できます。アプリの最低対応バージョンと関係なく届くのは、地味に嬉しいところです。 オファーが引き換えられたかどうかはサーバー側で確認できます。署名付きトランザクションに新しいオファー種別が入ります。

{
  "offerType": 5,
  "offerIdentifier": "Yoga_2026_cancel_free_3m",
  "offerDiscountType": "FREE_TRIAL",
  "offerPeriod": "P3M"
}

offerType はトランザクションに「どの種類のオファーが絡んだか」を刻むフィールドで、これまで4種類あったものに追加で Retention Offer が加わった形です。

offerType 種類
1 お試し
2 プロモーション
3 オファーコード
4 再獲得
5 Retention Offer

メッセージを用意する方法は2つある

ここまでが主に「解約確認画面に何が出るか」の話です。次は、その表示をアプリ側がどう用意するかです。 方法は2つあって、手軽さが大きく違います。App Store Connect で設定するだけの方法と、自前のサーバーを立てて顧客ごとにリアルタイムで出し分ける方法です。順に見ていきます。

方法1: App Store Connect 側での設定

App Store Connect 上でメッセージ・画像・オファーを設定し、対象のサブスクリプションにマッピングするだけです。自前のサーバー実装は不要で、Apple 側が表示を担います。

流れはこうです。

  1. ローカライズ済みのメッセージ文言を作る
  2. 任意で Asset Library の画像、Retention Offer を添える
  3. 1つ以上のサブスクリプションにマップする
  4. Sandbox 環境でテストして公開

方法2: リアルタイム API 型(ユーザーごとの出し分け)

方法1の弱点は、全員に同じものしか出せないことです。 新しく発表された Retention Messaging API を使うと、「誰に・何を出すか」を自社のデータで決められるようになります。 誤解しやすい点を先に書いておくと、解約の理由そのものが Apple から届くわけではありません。リクエストに入っているのは「誰の契約か(originalTransactionId)」までです。ただ、この ID で自社のユーザーデータを引けば、手持ちの情報が使えます。例えば、購読してからどれぐらいか、最後にアプリを開いたのはいつか、月額プランか年額プランか、過去にオファーで引き止めたことがあるかなどがあるでしょう。 理由そのものは分からなくても、こうしたデータから仮説は立てられます。 出し分けのロジックが自前のサーバーにあることで A/B テストでの検証も可能になるはずなので、その仮説を検証することもできそうです。

次に、仕組みを見ていきます。 Retention Messaging API を使うと、解約操作が起きたまさにその瞬間に、App Store から自前のサーバーへ問い合わせが来ます。

// App Store からのリクエスト
{
  "originalTransactionId": "123456789",
  "appAppleId": 6745974591,
  "productId": "Yoga_summer_2026",
  "userLocale": "en-US",
  "requestIdentifier": "c03248af-dd76-4e9b-9c1e-4489cd19a768",
  "environment": "Production",
  "signedDate": 1780920000000
}

これに対して、このユーザーに何を出すかを返します。返せる応答は3種類です。

① メッセージ

{ "message": { "messageIdentifier": "551ee7c0-..." } }

② プラン切替の提案(alternateProduct)

{
  "alternateProduct": {
    "messageIdentifier": "ed7f25fc-...",
    "productId": "Yoga_summer_2026_annual"
  }
}

同じサブスクリプショングループ内の別プランへの乗り換えを提案できます。「月額×12ヶ月コミット」の新プランタイプとも連動していて、たとえば年額プランを解約しかけた人に「月額の12ヶ月コミットなら続けやすいですよ」という導線が作れたりしそうです。

③ プロモーショナルオファー

{
  "promotionalOffer": {
    "messageIdentifier": "80135e2b-...",
    "promotionalOfferSignatureV2": "eyJhbGciOiJFUzI…"
  }
}

プロモーショナルオファーは、開発者が特定のユーザーだけに提供できる限定オファーです。 対象ユーザー以外には利用されないように、開発者のサーバーが秘密鍵を使って「このユーザーに、このオファーを適用してよい」という署名を発行します。アプリはこの署名を使って、オファーが正しく発行されたものかを確認します。 promotionalOfferSignatureV2 は、このデジタルな許可証を標準的な形式である JWS(JSON Web Signature) で表現する新しい仕様です。

ところで、ここまでの応答例が messageIdentifier という ID しか返していないことに気づいたでしょうか。メッセージの文言や画像の実体は、あらかじめ Apple に登録しておく設計になっています。解約フローの真っ最中に文言ごと送るのではなく、実体は事前登録しておいて、その場では「どれを出すか」を ID で選ぶだけです。 この事前登録を担うのが、管理用のエンドポイント群です。メッセージや画像の登録のほか、リアルタイム問い合わせの受け口 URL の設定、性能テストの実行もここで行います。

POST   /messages                          // メッセージ登録
GET    /messages                          // 登録済み一覧
DELETE /messages/{messageId}              // 削除

POST   /images                            // 画像登録
GET    /images
DELETE /images/{imageId}

POST   /defaultMessages                   // デフォルトメッセージ設定
GET    /defaultMessages/{productId}/{locale}
DELETE /defaultMessages/{productId}/{locale}

POST   /realtimeUrl                       // 受け口URLの設定
GET    /realtimeUrl
DELETE /realtimeUrl

POST   /performanceTests                  // 性能テスト
GET    /performanceTests/{testId}

フォールバックは段階的

リアルタイム応答が使えない・不正な場合は App Store Connect で設定されたものに、それも無ければ API で設定したデフォルトメッセージにフォールバックされます。 また、Sandbox には自社サーバーの応答性能を測るためのテスト用エンドポイントが用意されています。解約フローの中で同期的に呼ばれる API なので、応答が遅ければ体験を壊します。本番前にここで確認しておく、という建て付けだと理解しています。

まとめ

  • Retention Messaging は、解約確認画面という最後の接点に初めて介入できる機能
  • 用意する方法は2つ。サーバー不要の App Store Connect 設定型と、ユーザーごとに出し分けるリアルタイム API。フォールバックも整理されている
  • 返せるのはメッセージ、プラン切替提案、プロモオファーの3種
  • メッセージや画像の実体は事前登録しておき、リアルタイム応答では ID で選ぶだけ。Sandbox には応答性能のテスト用エンドポイントも用意されている

解約はこれまで、起きてから初めて知るものでしたが、今回からは解約を思いとどまってもらうための施策を、解約のタイミングに合わせて出せるようになります。サブスクリプションを運営しているチームは、サービス提供が始まる前に、どんなメッセージを出すか、どんなオファーを用意するかを検討しておくとよさそうです。