はじめに こんにちは、ZOZOTOWN開発2部Androidブロックの大江です。普段はZOZOTOWN Androidの開発を担当しています。 ZOZOTOWN Androidは10年以上にわたって開発されています。機能追加を重ねる中で、特定のモジュールが肥大化したり、モジュール構成が複雑になったりしたため、改善に取り組んでいます。 本記事では、こうしたモジュール構成について、課題とその解決に向けて段階的に進めてきた取り組みを中心に紹介します。 目次 はじめに 目次 背景・課題 段階的に整理を進める方針 dataモジュールの疎結合化 kaptの隔離 coreモジュールとinfraモジュールの依存方向の反転 得られた効果 まとめ 背景・課題 まず、ZOZOTOWN Androidの現在のモジュール構成の概要を紹介します。 app :画面や機能を担う各モジュールを束ね、アプリとして組み立てる役割を持つモジュール feature :画面や機能ごとに分割された実装を置くモジュール core :複数の機能から参照される古い共通処理を置くモジュール(将来的には解体したい) legacy :新しいアーキテクチャへの移行が済んでいない実装を置くモジュール(将来的には解体したい) data :APIやDBへのアクセスを担うモジュール infra :外部サービスとの通信など基盤的な処理を担うモジュール domain :APIやDBの実装から独立したユースケース(業務ロジック)を置くモジュール 現在、ZOZOTOWN Androidは77個のモジュールに分割されています。そのうち app モジュールは特に肥大化していて、単体でコードベース全体の約30%を占める規模になっています。これは feature モジュールへの切り出しが済んでいない画面の実装が app モジュールに残り続けていることが主な要因です。また legacy ・ core モジュールは、責務が曖昧になったり依存関係が複雑になったりして、将来的な解体が難しくなっていました。 こうした状況の中、機能開発を進める中で以下の問題が発生していました。 legacy モジュールと core モジュールに対する参照が増え続けて、解体が先送りになり続ける ビルド時間とユニットテストの実行時間が延び続ける 段階的に整理を進める方針 これらの問題を解決するために、大規模になっているZOZOTOWN Androidのモジュール構成を一気に理想形へ整理し直すのは、コストもリグレッションのリスクも大きくなります。そこで対応コストと得られる効果を見比べながら、優先順位をつけて段階的に手を入れていくことを考えました。 優先順位をつける際は、次の3点を意識しました。 今後も機能追加が続く前提で、繰り返し効果が積み重なる対応かどうか 対応コストに対して、ビルド時間や開発のしやすさへの効果がどれくらい見込めるか AIを活用することで対応コストそのものを下げられるか これらを踏まえて、次の3つの取り組みを選びました。 data モジュールの疎結合化:新しく作るRepositoryを疎結合な構造に強制できれば効果が積み重なるうえ、既存の実装にはほとんど手を入れずに進められるため対応コストも小さく、最初に着手した対応 kaptの隔離: @BindingAdapter を使った実装を1つのモジュールへ集約するだけで済み、対応コストが小さい一方、ビルド時間の短縮はチーム全体の開発体験に直結するため優先度を上げた対応 core モジュールと infra モジュールの依存方向の反転:複雑な依存関係を人手で洗い出すのは時間がかかりそうだが、AIに事前検証させることで対応コストを下げられる見通しが立った対応 ここからは、実際に進めた3つの取り組みを順に紹介します。 data モジュールの疎結合化 API・DBアクセスを担う data モジュールに配置されているRepositoryの中には、interfaceが設けられていないものがありました。こうしたRepositoryは legacy モジュールや core モジュールの実装に密結合しており、使用するたびに両モジュールへの参照が増えてしまいます。その結果、 legacy モジュールと core モジュールの解体コストが上がるという悪循環に陥っていました。 そこでまずはこの悪循環を断ち切ることが必要だと判断し、 data モジュールを次の4つに分割して新しく作るRepositoryは疎結合化を強制できるようにしました。 data:definition :interfaceとDTOだけを置くモジュール data:implementation :新しい設計に沿った実装を置くモジュール data:legacy :既存の実装をそのまま引き継ぐ受け皿 data:di :DIのバインディング定義だけを行うモジュール 利用側は data:implementation ではなく data:definition と data:di にだけ依存する構成にしました。実装クラスを直接使おうとすればビルドが失敗するので、コードレビューに頼らずビルド構成で疎結合を保証できました。 data:legacy は単なる未整理の実装置き場ではなく、新しい設計に沿った実装を既存の実装から隔てる腐敗防止層として意図的に位置づけました。この位置づけによって、既存のRepositoryを全件移行しきる前から、新しい設計を安全に並行導入できる状態を作れました。 legacy ・ core モジュールと異なり、 data:legacy はRepositoryの移行が進むにつれて中身が減っていく受け皿であり、移行完了後にはモジュールごと解体できる見通しを持っています。 既存の実装にはほとんど手を入れずに済むため対応コストは小さく、そのうえ新しく作るRepositoryが増えるたびに効果が積み重なります。この2点から、3つの取り組みの中でも最初に着手する対応として選びました。 kaptの隔離 kaptはJavaスタブを生成する必要があるため、ビルド時間を圧迫する要因として知られています。ZOZOTOWNでもモジュールごと、Gradleのタスクごとのビルド時間を計測しました。その結果、kaptに関連する処理がビルド時間の大半を占めていることがわかりました。原因は、Data Bindingの @BindingAdapter を使った実装があちこちのモジュールに散らばっていたことでした。kaptはモジュールごとに個別の注釈処理タスクが実行されるため、同じアノテーションを使うコードの分散は、その分だけ処理コストの積み重なりを招きます。 そこで @BindingAdapter を使う実装だけを ui-databinding という専用モジュールに集約し、それ以外のモジュールからkaptの設定を削除しました。散らばっていた実装を1つのモジュールへ集約するだけで済むため対応コストは小さく、ビルド時間の短縮という効果はチーム全体の開発体験に直結します。この対応コストと効果のバランスから、優先度を上げて取り組みました。 この対応によって複数のモジュールでビルドにkapt関連の処理が実行されなくなり、GitHub Actionsの4コアCI環境でのビルド時間が30分から18分へと、40%程度短縮できました。この数値はCI環境限定のものですが、ローカル開発環境でも同様にビルド時間の短縮を体感できています。なお、現在もkaptが残っているのは core ・ ui-databinding と、機能単位のモジュール2つのみです。 core モジュールの build.gradle には今も「Epoxyを削除できたらkaptも削除する」という趣旨のコメントが残っており、対応がすべて終わったわけではありません。 core モジュールと infra モジュールの依存方向の反転 legacy モジュールと core モジュールは様々なモジュールで使用されていて、依存関係が複雑になっていることもこれらのモジュールの解体を先送りさせる原因になっています。複雑に絡み合っている依存関係を人手で紐解いて解体するのはコストが高く、リファクタリングとして優先度が上がらない状況でした。 しかし、Claude CodeなどのAIが登場し、こうした人手だと時間のかかる調査や検証を短時間で行えるようになりました。 そんな中、ある機能の開発を進めている際にAPI通信を担う infra モジュールが共通処理を置く core モジュールへ依存していて、理想とは逆の方向の依存関係を持っていることに気づきました。この向きの依存関係だと、機能開発に必要だった core モジュール側の新しい実装から infra モジュール側の既存パーサーを直接参照できません。そのためinterfaceと実装を分けてDIで注入するという、本来不要なはずの回り道の実装が必要になっていました。 そこでAIに、依存方向を反転させる案を別ブランチで検証させました。依存関係の定義を反転させて、関連するクラス群も infra モジュール側のパッケージへ移動しました。ロジックの変更を伴わず、ファイルの移動と参照先の付け替えだけで完結する変更だと分かりました。 この見極めが、AIに実装まで任せる決め手になりました。挙動を変えるロジック修正が必要な変更であれば、AIが提案した内容でも人間が変更の妥当性を細かく確認する必要がありますが、機械的な変更だけで済む場合は検証から適用までを任せやすいと感じています。 一般的に依存関係の変更は影響範囲が広く、大量のソースコードを変更することになります。この見通しを短時間で立てられたことが、着手の判断を後押ししました。 実際の対応でも、AIが作成したブランチをベースに実装を進め、既存のビルドとユニットテストがすべて通ることを確認できましたが、影響範囲の広さから対応コストは高そうに見えました。しかし、AIによる事前検証でその判断コスト自体を下げられたことが、優先して取り組む決め手になりました。この実績によって、今後の依存関係の整理や legacy ・ core モジュールの解体を加速させられる目処が立ちました。 得られた効果 この3つの取り組みを通じて、次の効果が得られました。 data モジュールの疎結合化:新しく作るRepositoryが legacy ・ core への密結合を避けられる構造になり、参照が増え続ける悪循環を断ち切れました kaptの隔離:GitHub Actionsの4コアCI環境でビルド時間を30分から18分(40%程度)に短縮できました core モジュールと infra モジュールの依存方向の反転:機能開発に不要だった回り道の実装を解消できました。加えて、AIに事前検証させることで大規模な依存関係の変更に着手する判断を素早く行えるという実績もできました まとめ 本記事ではZOZOTOWN Androidのモジュール構成に対する課題とその解決方法を紹介しました。大規模なアプリのモジュール整理を一括ではなく段階的に進める前提で設計し、移行しきれていない実装の受け皿を腐敗防止層として用意することで、既存実装への影響を抑えながら新しい設計を導入できました。また依存関係の大規模な組み替えは、AIに実現可能性を先に検証させることで、着手の判断を素早く行えました。マルチモジュール構成の整理を検討している方がいれば、ぜひ本記事を参考にしてみてください。今後は残っている legacy ・ core モジュールの解体や、 app モジュールに残る画面の feature モジュールへの切り出しなど、引き続きモジュールの整理を進めていきたいと考えています。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、ZOZOTOWN開発本部Webバックエンドブロックの和氣です。普段はZOZOTOWNのバックエンドを担当しています。 日々の開発にClaude Codeを使っています。使ううちに、仕様や経緯を毎回プロンプトで説明し直していることに気づきました。そこで、Claude Codeとの会話をMarkdownで残し、作業に合わせたコンテキストをClaude Code自身が組み上げるようにしました。以降、残したMarkdownを「メモリ」と呼びます(Claude Code標準のメモリ機能とは別物です。違いは後述します)。 現在、メモリは個人に閉じず、職種を跨いで十数人で共有しています。他の人が調べたこと、意思決定の背景、その人の考えまで、Claude Codeで追えるようになりました。 本記事では、この仕組みと運用、そしてチームで共有してから起きた変化をご紹介します。 目次 はじめに 目次 背景と課題 同じ説明を何度も書いていた 文脈はコードの外にある コンテキストはセッションを跨げない 採用したアプローチ 正本をMarkdownにした 検索用の索引を別に持つ 標準のメモリ機能との違い 仕組みと運用 メモリ専用のGitリポジトリ メモリディレクトリの構成 保存フロー 読み込みフロー 関連メモリの想起 実作業スキルとメモリの接続 メモリの共有 効果 Claude Codeへ同じ説明をくり返さなくなった プロンプトに書くのはまだメモリにない文脈だけ メモリとスキルがつながり人の作業は文脈集めに寄った チームの記憶になった メモリを通してチームにコンテキストが共有された メモリを通して過去の担当者に聞ける 得られた知見 メモリはなんでも残して検索を強くする メモリの矛盾は問題にならない メインセッションはコンテキストのフィルターにする 見えてきた課題 メモリの鮮度 想起の精度 検索のスケール 今後の展望 メモリを使う人をプロジェクトに関わる職種へ広げる Claude Codeから保存を促すようにする 案件・機能についての文脈集めをClaude Codeのルーティンに任せる まとめ 背景と課題 同じ説明を何度も書いていた 同じ案件・機能のなかで、一度Claude Codeに説明したことを、何度もプロンプトに書いていました。調査を依頼するとき、設計を相談するとき、実装を任せるとき、レビューを見てもらうとき、また同じことを書いているな、と思っていました。 思い返すと、中身は毎回ほとんど同じです。作業ごとに組み合わせが変わるだけで、どれも前にどこかで書いたものでした。 文脈はコードの外にある 毎回プロンプトに書いていたのは、コードにない文脈でした。仕様をどう調整したか、なぜその設計にしたか、調べて何が分かったかなどです。レビューで指摘された点や、そのとき考えた懸念と判断の理由も同様です。 こうした文脈があるのは、次のような場所です。 Slackのやり取り Confluenceなどのドキュメント ミーティングでの会話 GitHubのレビューコメント 人の頭の中 どこに何があるかは、担当者しか知りません。 コンテキストはセッションを跨げない セッションは、毎回新しいコンテキストで始まります。文脈を集めて渡しても、Claude Codeが覚えているのはそのセッションのあいだだけです。次のセッションへは持ち越せず、時間をかけて合わせた認識も一緒になくなります。 同じセッションを長く使い続けても、同じです。コンテキストには上限があり、近づくと会話が自動で要約に置き換わって、細かいやり取りが落ちます。 次のセッションで前回の文脈が必要になれば、集め直してもう一度プロンプトで渡します。ただ、渡した本人でも、すべての文脈を渡せているかは分かりません。文脈を1つ落としたまま進めると、アウトプットが期待と異なることがあります。何が足りなかったのかは、そのとき初めて気づきます。 採用したアプローチ セッションでの会話を、Claude Codeにメモリとして残してもらうようにしました。正本はMarkdownファイルで、検索用の索引はそこから切り離してローカルのSQLiteに置いています。 正本をMarkdownにした 人とClaude Codeが同じファイルをそのまま読めます。専用のビューアーは要らず、気になるところは、その場でファイルを修正できます。Gitで管理できるので、いつ何を変えたかは履歴に残ります。 検索用の索引を別に持つ メモリは作業を重ねるほど増えていきます。索引には2026年8月時点で約7,000件が載っていて、この中から欲しい数件を取り出せるかが問題です。 正本のMarkdownは、grepでも探せます。ただしgrepが見るのは、文字列が一致するかどうかです。よく使われる言葉ほど大量に当たり、少し違えば1件も出ません。たとえば「不具合」で探しても、「バグ」と書かれたメモリは出てきません。 そこで、検索用の索引を別に作り、2種類の検索を並走させています。 全文検索 :書いた言葉にそのまま一致する。チケット番号のような識別子に強い 意味検索 :文の意味を数値にして、近さで探す。「不具合」と打っても「バグ」のメモリが候補に入る 検索のたびに両方を走らせ、順位を混ぜて上位だけを返します。検索のログを集計すると、1件も返らなかったのは2.7%、欲しいメモリが3位以内に入っていたのは68.9%でした。 SQLiteにした狙いは、拡張のしやすさです。検索の手法をあとから足したり替えたりできるようにしました。 索引はGitで管理しません。正本のMarkdownから、いつでも作り直せるからです。 標準のメモリ機能との違い この仕組みを作り始めたあとで、Claude Codeにも 標準のメモリ機能 が入りました。ですが、いまも自前のメモリを使い続けています。理由は、量と共有と拡張性です。 標準のメモリは、起動時に MEMORY.md という一覧ファイルだけを読み、必要なときに詳細を開きます。ただしその一覧から読み込まれるのは、先頭200行か25KBまでです。自前の仕組みはもっと多くのメモリをためるつもりで作ったので、この上限では足りませんでした。 共有もできません。標準のメモリは手元のマシンに閉じているため、他のメンバーからは見えません。 調査や設計といった作業のスキルそのものに、メモリの読み書きを組み込みたいと考えていました。標準の機能では、そこまで手を入れられません。 仕組みと運用 メモリ専用のGitリポジトリ メモリ専用のGitリポジトリを1つ用意しています。開発リポジトリに実体は置かず、 .claude/memories からシンボリックリンクで参照します。リンク先はどの開発リポジトリからでも同じで、案件が変わっても、メモリは1か所に集まります。 開発リポジトリA/.claude/memories ─┐ 開発リポジトリB/.claude/memories ─┼─→ メモリ専用リポジトリ 開発リポジトリC/.claude/memories ─┘ メモリディレクトリの構成 置き場所は、案件・職種・担当者・作業単位・フェーズの5つの軸で決まります。 .claude/memories/ メモリ専用リポジトリへのリンク └── <案件>/ └── <職種>/ ├── 01-context/ 前提・決定事項 ├── 02-meeting/ 議事録 ├── 03-research/ 調査結果 ├── 04-feature/ チーム共通の機能作業 ├── 05-users/ │ └── <担当者>/ │ ├── 01-inbox/ 未整理のメモリ │ ├── 02-feature/ 機能単位の作業 │ │ └── <機能名>/ │ │ ├── 01-concern/ 困りごと │ │ ├── 02-context/ 仕様・要件 │ │ ├── 03-research/ 調査 │ │ ├── 04-design/ 設計・計画 │ │ ├── 05-implementation/ 実装記録 │ │ ├── 06-review/ レビュー結果 │ │ └── 07-testing/ テスト・証跡 │ └── 03-issue/ 課題単位の作業 │ └── <課題名>/ │ ├── issue.md 課題そのもの │ ├── research.md 調査 │ ├── plan.md 進め方 │ └── result.md 結果 └── 06-log/ セッションログ 同じ役割のディレクトリが、2つの階層に出てきます。それぞれスコープが異なり、上の 01-context/ や 03-research/ は案件レベル、機能の下の 02-context/ や 03-research/ は機能レベルです。 担当者ごとの階層は、コンフリクト対策です。最初は案件の下にメモリを直接置いていました。同じ案件を複数人で進めると、同じファイルを取り合うようになったので、担当者ごとに分けました。 個人のディレクトリなら、書きかけのメモや雑多な記録をそのまま置けます。チームに共有するものは、案件共通のディレクトリへ置きます。 保存フロー 保存は、「メモリに保存して」とClaude Codeへ頼むようにしました。人が言うのはこれだけで、置き場所や名前は決めません。 メモリの保存を頼むと、保存の手順をまとめたスキルが動きます。案件・職種・担当者・作業単位・フェーズを判定して、先ほどのツリーの置き場所を決めます。あとで探すための要約とタグを作り、ファイルの先頭(frontmatter)に付けます。保存の前には、同じ置き場所に似たメモリがないかを探します。あれば、そのメモリを示して、書き足すか新しく作るかを聞きます。 このフローにしているのは、メモリを読み込む際に検索しやすくするためです。そのための工夫は3つあります。 置き場所をツリーで判定する :メモリを読み込むとき、ディレクトリも検索の要素の1つになる。検索の精度を上げるために、適切なディレクトリへ機械的に保存されるようにしている 要約とタグを先に作る :メモリを読み込むとき、検索の候補として見えるのは、置き場所と要約とタグだけ。だから保存するときに要約とタグを付けておく。付けたあと実際に検索して、そのメモリが上位に出るかまで確かめている 文脈の単位で積む :置き場所と類似度で、文脈が続いているかを判定する。文脈が違えば、似ていても別のメモリになる。検索したときに、文脈を追いやすくしている 役割分担は、判断がClaude Code、実処理がMCPツールです。 判断(Claude Code) :置き場所や名前を決める。要約とタグ、関連メモリへのリンクを付ける 実処理(MCPツール) :ファイルへ書き込む 読み込みフロー 読み込みも同じです。「この機能のメモリを読んで」と頼みます。手がかりは機能名でなくても構いません。案件名やチケット番号でも、ふわっとした言い方でも探してくれます。 絞り込みの手順は2段階です。Claude Codeはまずプロンプトを解析して検索クエリを決め、先ほどの索引で数千件から数十件を取り出し、並べ替えたうえで候補を10件ほどに絞ります。 次に候補の要約に目を通し、質問の意図に合うものを数件選んで、本文を見出しと要点だけに圧縮して読みます。本文にリンクされている別のメモリがあれば、そちらもたどります。 1回分の流れをイメージで示します。機能名、パス、要約はすべて架空のものです。 人のプロンプト 商品ページの表示項目、前回どう決めたか読んで Claude Codeが組み立てた検索クエリ 商品ページ 表示項目 仕様 決定 索引が返した候補(上位10件のうち2件。置き場所と要約) 02-feature/product-page/02-context/requirements.md 商品ページの要件。表示する項目と並びを確定 本文からのリンク先: 01-context/product-info-rule.md (商品情報の共通ルール) 02-meeting/2026-06-18-定例.md 定例の議事録。商品ページの表示方針を合意 Claude Codeが読んだもの 質問の意図に合う1と2を選び、見出しと要点だけに圧縮して読みました。1の本文からリンクされていた共通ルールも、たどって読んでいます。こうして、機能の要件、定例の議事録、案件共通のルールと、置き場所の違うメモリがつながって、1つのコンテキストになります。 この形にしているのは、コンテキストを膨らませずに、数千件から欲しい数件を取り出すためです。そのための工夫は3つあります。 Claude Codeに検索をさせない :本文を読み比べるのは索引の仕事で、Claude Codeは検索クエリを渡して、置き場所と要約とタグを受け取るだけ。メモリが何千件に増えても、検索でコンテキストが膨らむことも、トークンを余計に消費することもない 1件に決め打ちしない :検索は、正解を上位10件に入れるのは得意でも、1位に当てるのは苦手。だから1位だけを読まず、10件の要約を見比べてClaude Codeが選ぶ 本文は圧縮して読む :数件でも全文を読むとコンテキストを圧迫するので、まずは質問に関係する見出しだけに絞った圧縮版で読む。足りなければ圧縮を緩めて、読む範囲を広げる 読み込みの分担は次のとおりです。 判断(Claude Code) :検索クエリを決める。候補の要約とタグを見て、読むものを選ぶ 実処理(MCPツール) :索引を検索する。本文を圧縮する。リンクを解決する 関連メモリの想起 ここまでは、人が「読んで」と頼んでからメモリを読んでいました。想起では頼みません。会話している内容に近いメモリがあれば、Claude Codeが自分から読みにいきます。 プロンプトを送るたびに、hooksが裏で動いて、読み込みフローと同じ方法で検索します。近いかどうかは1回では決めず、ターンを跨いでスコアを積み上げます。流れは次のとおりです。 検索方法 :全文検索と意味検索で、近いメモリを探す スコアリング :プロンプトのたびに候補へスコアを付け、それを累積していく。スコアは、検索での順位と、プロンプトと重なる語の多さで決まる。ただし、チケット番号や識別子のような特徴のある言葉が、プロンプトと候補の両方に出てくるときだけ スコアの減衰 :スコアは会話が進むにつれて下がり、話題が続かない候補は消えていく Claude Codeに知らせる :しきい値を超えたメモリだけが、「近い記憶がある」としてプロンプトに添えられる チケット番号のような特徴のある言葉が一致すれば、1回でしきい値を超え、ありふれた言葉では、同じ話題が何ターンか続いてはじめて超えます。できるだけ、会話に関係のないメモリを想起させないようにしています。知らされたあとに実際に読みにいくかは、Claude Codeに会話の流れを見て決めてもらいます。 実作業スキルとメモリの接続 保存と読み込みは、プロンプトで頼まないと動きません。作業に集中していると頼むのをよく忘れ、どちらも作業とは別の一手間になっていました。 そこで、調査や設計などを進める実作業スキルに、メモリの読み書きを埋め込みました。人が頼むのは「調査して」だけです。メモリのことは何も言いません。 機能を開発するときは、先ほどのツリーにあった <機能名>/ のディレクトリを作り、以降はその中で作業します。スキルはフェーズごとに1つずつあり、上から順に1本のフローとして流れます。どのスキルも、起動すると前のフェーズまでのメモリを自動で読み込み、終わると決まった先へ成果を書き出します。読み書きの場所が決まっているので、スキルどうしは互いを知らなくてもつながり、フェーズを足すときも置き場所を決めるだけで済みます。 メモリは、フェーズが来る前に置いておくこともできます。懸念なら 01-concern/ 、決まっている仕様や会議のメモなら 02-context/ 、調べてあった内容なら 03-research/ です。ディレクトリごと読み込まれるため、1つのファイルにまとめる必要はありません。 この一連のフェーズを、まとめて実行するスキルも用意しました。人が関わるのは、やりたいことを会話で詰めるところまでです。そのあとは、調査からレビューまで無人で進みます。 フローに乗る作業では、メモリの保存や読み込みを頼む場面がなくなりました。 メモリの共有 保存したメモリは、この仕組みを使うメンバー全員が読めます。いま使っているのは十数人です。案件や職種を問わず、同じ1つのリポジトリへ集まります。一人が調べたことや決めた設計を、チームの資産にするためです。 Claude Codeが応答を終えるたびに、hooksがメモリの差分を確認します。あれば、担当者専用のブランチを作り、コミット・プッシュ・プルリクエスト作成まで自動で進めます。人が手元でGitを操作することはありません。 コンフリクトはめったに起きません。書く場所が担当者ごとに分かれているからです。まれに起きてもhooksが自動で直そうとし、できなければ人に知らせます。 mainへのマージだけは自動化せず、担当者に任せています。破壊的な差分が勝手に入るのを避けるためです。区切りのよいところで中身を見てから、手でマージします。 マージされたメモリは、他のメンバーのローカルにも自動で反映されます。プッシュするときと同じく、応答の終わりにhooksが最新のmainを取り込みます。 効果 メモリを使うと、3つの変化がありました。同じ説明のくり返しが消えたこと、プロンプトがメモリにない文脈だけで済むこと、そして人の作業が文脈集めに寄ったことです。 Claude Codeへ同じ説明をくり返さなくなった 以前は、自分で調べた結果や決めた設計、その理由を毎回プロンプトへ書き並べていました。いまは「この機能のメモリを読んで」の一言でClaude Codeとの認識を合わせられます。それまでの作業で書かれたメモリが機能ディレクトリにたまっているからです。 この読み込みが、直近2か月半で約800回ありました。1つの文脈をClaude Codeへ説明し直すたびに、5分や10分はかかります。5〜10分×800回で、67〜133時間ぶんの説明が浮いた計算です。 作業のコンテキストが連続していることも、説明のくり返しがなくなった要因です。保存したメモリは、次の日にはまた読まれます。読み返されたメモリのうち半分は、保存から18.7時間以内に読まれていました。 Claude Codeへ説明し直すことがなくなり、時間削減につながりました。 プロンプトに書くのはまだメモリにない文脈だけ プロンプトに書くのは、主に新しい要望やいま決まったばかりのことです。作業に要るコンテキストを土台とタスク特有の2つに分けると、土台の部分はClaude Codeがメモリをつなぎ合わせて作ります。人が書くのは、タスク特有の部分だけです。 今回プロンプトに書いたタスク特有のコンテキストも、作業が終わればメモリとして残ります。次の作業では、それが土台側に回ります。実際に、保存したメモリの約3割は、後日の別の作業で土台として読み返されています。土台は作業のたびに厚くなり、人がプロンプトに書く分は減っていきます。毎回、いま話したいことに集中できます。 作業によって、必要になる文脈は違います。案件概要のような汎用的な土台を毎回渡すと、今回の作業との間に差が出て、その差を人が説明で埋めることになります。メモリを使えば、作業に合わせた土台が組み上がるので、その差が小さく、人はタスク特有の説明だけに集中できます。実際に、複数のメモリを読んだ作業は113回ありましたが、同じ組み合わせは一度もありませんでした。たとえばある判定ルールのメモリは7つの作業で読まれていて、一緒に読まれたメモリは7回とも別でした。 土台を説明せずに済むので、Claude Codeとの認識合わせが速くなり、細かいところまで合わせられるようになりました。 メモリとスキルがつながり人の作業は文脈集めに寄った 土台のコンテキストはメモリから組み上がるので、人はタスク特有のことだけを説明すれば、Claude Codeと詳しく認識を合わせられます。そうして合わせた認識は、メモリと実作業スキルがつながっているので、そのまま調査・設計・実装へ流れていきます。人が途中で説明し直す場面はありません。 実際に開発の進め方が、これまでと変わってきています。 文脈を集める :案件の情報を集めて、メモリに残す やりたいことを伝える :作る機能の概要を見て、Claude Codeへざっくり伝える。数ターンで認識を合わせる コンテキストが組み上がる :やりたいことと、メモリから組み上げたコンテキストが合わさって、このあとの作業に必要なコンテキストが決まったディレクトリへ置かれる 開発フローが流れる :調査・設計・実装・レビューと順に進み、プルリクエストができる プルリクエストをレビューする 以前は、各作業フェーズで、人がプロンプトで文脈を渡していました。いまは、人の作業が文脈を集めて渡すところに寄っています。 Slackで仕様が決まったとき、会議で前提が変わったとき、レビューで方針が変わったとき。そのたびに、その場の文脈をメモリに残していきます。アウトプットの品質は、タスク開始前に、点在する文脈をどれだけメモリにできるかで決まります。 チームの記憶になった ここまでは、一人で使ったときの話です。 複数人で共有すると、想像を超えた効果がありました。他のメンバーが調べたことや決めたことを、自分のClaude Codeがそのまま読みます。あとからでも、誰が決めたことか、なぜそう決めたかを詳細に確認できます。 メモリを通してチームにコンテキストが共有された コンテキストが渡る方向は3つあります。 縦 :上流から下流へ。意思決定を背景ごと引き継げる 横 :同じ職種の間で。調査や判断を使い回せる 斜め :職種を跨いで。相手の前提に気づける 縦は、上流から下流へ渡ります。 上流の要件整理や他チームとの認識合わせは、プロジェクトリーダーがまとめて引き受けることが多く、上流の文脈はリーダーにたまります。リーダーは決まったところから、メンバーへタスクを渡していきます。その際、人から人へ伝わるのは結論が中心で、そこに至る背景や経緯は薄くなりやすいです。リーダーが決定事項をメモリに残しておけば、上流で決まった背景がそのまま下流へ渡るので、以下のような効果がありました。 リーダーとの会話が認識合わせだけで済んだ :メモリから意思決定とその背景・判断材料を詳細に読み込める。メンバーはリーダーへ聞きに行かず、リーダーもメンバー一人ひとりへ説明し直さずに済む(コミュニケーションコストの削減) 下流のコンテキストが詳細になった :リーダーの意思決定・背景・判断材料が、そのままメンバーのClaude Codeの作業コンテキストになる(アウトプットの品質向上) 実際に、設計と実装で担当の分かれた機能がありました。担当者は、Claude Codeにリーダーが残した設計メモリを読み込ませ、Claude Codeとの会話で認識を固めてからタスクを始めました。引き継ぎのミーティングは開かず、軽い認識確認だけで済みました。 横は、同じ職種のメンバー同士で渡ります。 同じ案件を複数人で進めていると、誰かが調べたエンドポイントの仕様や、決めた実装方針が、そのまま他のメンバーの役に立ちます。メモリなら、それが同じ場所に集まるので、以下のような効果がありました。 同じ調査をやり直さずに済んだ :一人が調べてメモリに残せば、あとの人はメモリを読むだけで済む(調査時間とトークン消費の削減) 実装の判断を、本人へ聞く前に引けた :メモリからその機能を作った人の意思決定を読める。APIの仕様がなぜその形なのかも、担当者へ聞かずに分かる(設計の手戻り抑制・コミュニケーションコストの削減) 他のメンバーの調査と判断が、自分の作業で使えるようになりました。 斜めは、職種を跨いで渡ります。 職種が違えば受け持つ層が異なり、バックエンドはAPIが何を返すかを、フロントエンドはそれをどう見せるかを決めます。会話に上がるのは主要な部分で、細部の前提までは確認できないことがあります。メモリなら、職種が違っても同じ場所に集まるので、以下のような効果がありました。 職種を跨いで食い違いに気づけた :相手が何を前提にしているかが、自分のClaude Codeにも見える。前提・認識のずれが早い段階で見つかる(手戻り抑制・アウトプットの品質向上) 実際に、バックエンドの設計中に、Claude Codeが自発的にフロントエンド担当者の設計メモリを読み込み、設計の食い違いを指摘してきたことがありました。ある画面にタブを出すかどうかを、バックエンドはAPIが判定してフラグで返すつもりでした。フロントエンドは画面側で判定するのでフラグは要らないつもりでした。片方のメモリだけを見ていたら、気づかないまま進んでいました。 メモリを通して過去の担当者に聞ける 案件を進めながら残してきたメモリは、案件が終わったあともそのまま残ります。担当者の頭の中にしかなかった細かい文脈が、チームの記憶として永続化され、あとから誰でも詳細まで見にいけます。 これまでは、設計書をあとから読み直しても決まったことが中心で、その背景・経緯までは分かりませんでした。このようなドキュメントは読み手のために清書するので、背景・経緯が落ちやすいです。メモリには、読み手向けの作り直しが入りません。作業したときのやりとりがそのまま残っていて、設計内容を当時の背景ごと教えてくれます。 こうして残ったメモリがあれば、以前の案件の仕様を聞かれても、担当者でなくても具体的に答えられます。実際に、以前の案件で作った画面の表示仕様について、他チームから影響確認の問い合わせを受けたことがありました。その際に回答したのは、当時担当ではなかったメンバーでした。当時の案件担当者が5か月前に残した調査・設計メモリから表示している値の参照経路を確認し、最新コードをチェックしてから回答していました。 一人ひとりがClaude Codeとの会話で残したメモリが、チームの記憶になりました。他のメンバーの調査や判断はその場で自分の作業に使え、終わった案件の文脈は担当者がいなくても引き出せます。使う人と職種が増えるほど、つながる文脈も増えていきます。 得られた知見 メモリはなんでも残して検索を強くする メモリは、1件で読み切るドキュメントではありません。小さな断片をたくさん残しておき、作業のたびに検索が関係する数件を拾い、Claude Codeがつなぎ合わせて1つのコンテキストにします。1件の中身よりも、メモリどうしがつながることに価値があります。 人が読むドキュメントは、きれいに書いて1か所にまとめ、粒度も揃えます。メモリを読むのは主にClaude Codeです。きれいに整える必要はなく、検索で見つかればそれで十分です。 最初は、メモリも人が読むドキュメントと同じように、矛盾を解消し、重複をまとめてから保存していました。手間がかかるため、保存の回数は減る一方でした。そこで、保存するメモリの取捨選択をやめ、細かい実装の判断も案件全体の方針も、迷ったら残すことにしました。代わりに検索を強くします。作業に関係ないメモリは検索の上位に来ないだけで、邪魔にはなりません。欲しい1件を上位に出せるかが要になるので、検索ロジックには手を入れ続けています。 なんでも残して検索を強くすることで、いいことが3つありました。 小さな判断も引ける :ドキュメントには書かれない細かい実装の判断や、何を心配していたかも残り、あとから検索で引ける あとで必要になるものを落とさない :あとで必要になるかどうかは、残す時点では分からない。1件まるごとではなく、一部分だけがあとで効くこともある 組み合わせが増える :メモリを増やすほど、検索で拾える組み合わせも増え、作業ごとに合った土台が組める メモリの矛盾は問題にならない この仕組みでは、矛盾や間違いはそもそも入りにくい構造になっています。案件を進めながら、その場の事実をメモリに残していくからです。 とはいえ、矛盾や間違いが混ざってしまうこともあります。ですが、それが原因で困ることはありませんでした。理由は2つです。 同じ話題の作業が続く場合 :正しいことを書いたメモリがあとから積み重なる。メモリには更新日が付いていて、Claude Codeは関連するメモリをまとめて読むので、食い違えば新しいほうを正として扱ってくれる 一度きりの話題で後続のメモリがない場合 :メモリを答えにせず、手がかりにして実装や仕様を確かめる。メモリで当たりをつけ、最新のコードで裏を取ってから答える メインセッションはコンテキストのフィルターにする 検索で拾った数件のメモリにも、いまの作業に要らない部分は残ります。そのままメインセッションで作業まで進めると、要らない文脈に引きずられます。そこで、メインセッションには作業をさせず、メモリから拾った文脈のフィルターにします。サブエージェントに、作業ごとに必要な文脈だけを渡して、実行してもらいます。 見えてきた課題 メモリの鮮度 コードや仕様は変わり続けるので、メモリは放っておくと古くなります。いまは、読み込むときにメモリが参照しているコードを見て、保存のあとで変わっていれば「古いかもしれない」と警告するところまでです。まだ、古いメモリが検索の上位に出てくるのは防げていません。次の課題は、鮮度をどう検索の順位に反映するかです。 想起の精度 想起には、課題が2つ残っています。1つはプロンプトです。Claude Codeへ「近い記憶がある」と知らせるだけでは、一度も読みにいきませんでした(110件で0%)。「答える前に思い出してください」と指示として書くと、ようやく3回のうち2回は読むようになりました(208件で65.4%)。適切なプロンプトを探っています。もう1つは誤発火です。関係のない場面でも想起が出てきてしまうので、その回数を減らしていきたいです。 検索のスケール メモリは捨てずに残していくので、件数は増え続けます。増えるほど、欲しい1件を上位に出すのは難しくなります。いまの約7,000件では取り出せていますが、これが数万件、数十万件へ増えたときにどうなるかは分かりません。メモリが増えた状態を擬似的に作って、検証するところから始めようと考えています。 今後の展望 メモリを使う人をプロジェクトに関わる職種へ広げる いまメモリを使っている十数人は、全員がフロントエンド・バックエンドエンジニアです。これからは、PMやQAのようなプロジェクトに関わる他の職種へ広げていきます。この仕組みは職種を選びません。 PM :開発側の調査結果や進捗をメモリから追える。検討の経緯を残してもらえば、開発側もその背景ごと引き継げる QA :設計や実装のメモリから、この機能はどう動くべきかを読み取って、テストの観点に使える 職種を跨いで文脈が1か所に集まれば、誰が決めたことでも背景ごと引けます。チームの記憶を、プロジェクト全体に広げていきます。 Claude Codeから保存を促すようにする 実作業スキル以外では、まだメモリ保存は人が行っています。「メモリに保存して」の一言が必要です。ここをClaude Codeから「保存しますか」と聞いてくる形に変えていきます。仕組みとしては、hooksでセッション内のコンテキストがたまったことを検知して、一定量を超えると保存を促すようなイメージです。残したくない情報もあるので、保存するかどうかは人が判断します。人が意識しなくても保存ができるようになれば、メモリ量の増加につながり、つなげられるコンテキストも増えます。 案件・機能についての文脈集めをClaude Codeのルーティンに任せる メモリを活用した開発では、タスクを始める前に案件・機能についての文脈をどれだけ集められるかで、アウトプットの品質が変わります。文脈を集めてClaude Codeへ渡すのは、まだ人の作業です。SlackやConfluenceに点在する文脈を集めてメモリにする作業を、Claude Codeの ルーティン で自動化します。人が文脈を集めなくても、Claude Codeが自分で文脈を集められる環境を整備します。 まとめ 本記事では、Claude Codeとの会話をMarkdownのメモリとして残し、次の作業で検索して使い回す仕組みをご紹介しました。一人で使うだけでも、Claude Codeへ同じ説明をくり返さずに済み、次の作業は前回の続きから始められます。チームで共有すれば、他のメンバーが調べたことや決めた理由も、自分のClaude Codeから引けます。担当者の頭の中にしかなかった文脈が、チームの記憶になりました。AIに同じ説明をくり返している方や、チームで文脈をどう共有するか悩んでいる方の参考になれば嬉しいです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、ZOZOTOWN開発2部Webバックエンドブロックのぐらです。普段はZOZOTOWNのバックエンド開発を担当しています。ZOZOTOWNのリプレイスでは、既存システムの仕様を正確に把握することが欠かせません。私たちはこの分析に AIを活用 してきました。しかし、人間による追加調査がまだまだ必要という課題を抱えていました。本記事では、AIによる既存システムの分析ワークフローを作り直した過程と、そこで得られた知見を紹介します。 目次 はじめに 目次 背景・課題 ZOZOTOWNリプレイスにおける既存システムの分析 AI分析で直面した3つの課題 AI分析ワークフローを作り直す 改善1 ── 計算で確定できることは計算で出す 改善2 ── 調査を機能単位からシステム横断へ広げる 改善3 ── 出力をMarkdownからHTMLへ変える 改善の効果 精度はどう変わったか レビューの仕方はどう変わったか POC段階で見えている限界 今後の展望 まとめ 背景・課題 ZOZOTOWNリプレイスにおける既存システムの分析 ZOZOTOWNのリプレイスでは、長年運用されてきた既存システムを読み解く場面が数多くあります。対象はVBScriptとASPで書かれた環境です。私たちはこの調査にClaude CodeのAgent Skillsを導入し、その取り組みを以前の記事で紹介しました。 techblog.zozo.com このときは、既存コードの調査を4つのスキルに分解しました。依存関係の追跡から調査結果のドキュメント化までをClaude Codeに任せる仕組みです。 ただし、前回の記事の最後では残った課題にも触れています。ひとつは、調査結果は得られても背景の理解が伴わない点です。もうひとつは、AIが大量の情報を要約して出力するため、人間が全項目を精査しきれない点です。本記事で紹介するのは、この2つに取り組んだ続きの話です。なお、ここで紹介する改善は、現時点ではPOC(概念実証)段階の取り組みです。 AI分析で直面した3つの課題 AIに既存システムを分析させる中で、次の3つの課題が見えてきました。 プロンプトを工夫しても、分析結果に抜け漏れが発生する 分析結果が断片的な情報にとどまり、人間が横断的に追加調査する必要がある 出力先であるMarkdownファイルでは、表現できることが限られる このうち1つ目には、具体例がありません。同じプロンプトで同じコードを分析させても、次に抜けるところまで一致するとは限らないからです。LLMの出力は確率的で、再現しません。 個別の抜け漏れを見つけてプロンプトで塞ぐ、という直し方が効かないのはこのためです。塞いだつもりの穴が、次の実行では別の場所に開きます。 残る2つについては、それぞれの改善とあわせて後述します。 AI分析ワークフローを作り直す これらの課題に対して、次の3つの改善を実施しました。 3つに共通する狙いは、人間のレビュー負担を減らすことです。AIに分析させる以上、その出力を人が確認する工程は残ります。ただ、対象が大きくなるほどレビューは追いつかなくなります。そこで、人が見なければならない範囲を機械で絞り込み、残った範囲は見やすい形にしました。 改善1 ── 計算で確定できることは計算で出す 抜け漏れをなくす手がかりとして、ThoughtworksのBirgitta Böckeler氏による次の記事を参考にしました。 martinfowler.com 「Harness engineering for coding agent users」は、harnessの設計を扱った記事です。harnessとは、モデルそのものを除いたAIエージェントの周辺システムを指します。その構成要素は2種類に整理されています。エージェントに先回りして文脈を与えるguides(feedforward)と、出力を後から検査するsensors(feedback)です。 そのうえで、guidesとsensorsは実行のされ方によってさらに2つに分けられます。記事から引用します。 Computational - deterministic and fast, run by the CPU. Tests, linters, type checkers, structural analysis. Run in milliseconds to seconds; results are reliable. Inferential - Semantic analysis, AI code review, "LLM as judge". Typically run by a GPU or NPU. Slower and more expensive; results are more non-deterministic. Computationalは決定的で速く、結果が信頼できます。テストやリンター、型チェッカー、構造解析がこれにあたります。対するInferentialはLLMによる意味的な判断です。扱える範囲は広いものの、遅くて高価なうえに結果が非決定的です。 この記事が想定しているのはコーディングエージェントですが、2つの分け方は既存システムの分析にもそのまま当てはまりました。本来Computationalに確定できることまで、Inferentialに委ねてしまっていないか。この観点でワークフローを見直し、計算で確定できることは計算で出す方針に切り替えました。推論に委ねる範囲が狭いほど、ハルシネーションが混ざる余地も狭くなります。 やったことは単純です。AIに分析させる前の段階で、まず分析対象のコードを構造化します。以前に作った軽量な言語サーバー(LSP)をスクリプトから呼び出し、静的解析でコードの構造を洗い出しました。実行されるコードを関数の単位で分解し、その一つひとつへ一意な識別子を与えておきます。 そしてAIには、分析結果を書くときに根拠となる識別子を必ず示させます。こうすると、抜け漏れが計算で見つかるようになります。洗い出した識別子のうち、どの分析結果からも参照されていないものがあれば、それが書き漏らしだからです。人間がAIの出力を読み返して抜けを探す作業は、識別子を突き合わせるだけの処理に変わりました。 この形にしてから、抜け漏れへの対処の仕方も変わりました。以前は、抜け漏れが出るたびにプロンプトを書き足していました。いまはまず、計算で洗い出す事実の種類が足りていなかった可能性を疑います。計算の側を直せば、以降は同じ種類の見落としが検証を通らなくなります。プロンプトを厚くするのではなく、計算で確定する範囲を広げる。これが今回の改善の実体です。 Böckeler氏の整理に当てはめてみます。事実の洗い出しがComputationalなguides、識別子の突き合わせがComputationalなsensorsにあたります。意味の解釈はInferentialに残し、その出力は別のAIによるレビューで検証しています。 改善2 ── 調査を機能単位からシステム横断へ広げる リプレイスは段階的に進みます。機能をひとつずつ新しい環境へ移していくのに合わせて、分析も機能単位で進めていました。ただ、この「機能単位」という切り方には、見落としやすい領域が存在します。 典型的なのが、Cookieやセッションのように、複数の機能から共有される「状態(state)」です。移行対象の機能だけを見ていると、書き込み側か読み取り側のどちらか一方しか視界に入りません。どちらか片方だけを記録しても、その状態が何を約束しているのかは定義しきれません。 さらにやっかいなのは、よく使われる状態ほど参照元があちこちに散らばることです。分析結果はどうしても断片的なところで止まり、その先は人間が横断的に追加調査して埋めていました。 そこで、影響のあるCookieやセッションは、機能単位での調査をやめ、システム横断で調べる方式に変えました。書き込んでいる箇所と読み取っている箇所をまとめて洗い出します。これまで人間が追加調査で補っていた部分も、調査ワークフローの中に組み込みました。 改善3 ── 出力をMarkdownからHTMLへ変える これまで分析結果はMarkdownで書き出していました。AIにとって扱いやすい形式だからです。生成と修正が素直に通りますし、差分も追えます。 ただ、読む側から見ると厳しい形式でもありました。分析の内容は情報量が多く、Markdownに落とすとどうしても縦に長くなります。全体をつかむにはスクロールが欠かせません。根拠を確かめるときは、ソースコードを別のウィンドウで開いて並べていました。分かりやすくする工夫の手段は、見出しと箇条書きと表くらいに限られます。 そして、ここまでの2つの改善がこの問題を大きくしました。計算で確定できる範囲を広げ、調査もシステム横断へ広げたぶん、出てくる情報量は確実に増えます。同じ形式のまま情報だけが増えれば、縦に伸びるだけです。 そこで、出力をHTMLに変えました。画面上で関数をひとつ選ぶと、関係する箇所が連動して絞り込まれます。コードから引けば、そのコードを根拠にした記述と、記述から起こした仕様がまとめて見えます。逆からたどっても同じです。 実際の画面ではなく、構成を示すイメージ図です 分析の本体は、結果を構造化してまとめたyamlで、HTMLはそのyamlを読みやすい形にレンダリングしたビューです。ビュー側には新しい情報を持たせていないため、yamlさえあればビューの形式は自在に変更できます。 改善の効果 精度はどう変わったか 一番大きな変化は、網羅を機械が判定するようになったことです。 分析対象の1画面には、分岐や入力、状態の読み書きといった事実が数千件あります。改善前は、この規模の出力に対して「抜けていないか」を人が読んで確かめるしかありませんでした。読み切れないので、確かめたつもりになるしかない、というのが実情です。 いまは、引用されていない識別子を数えるだけで答えが出ます。全部埋まっていれば網羅、埋まっていなければどれが足りないかまで分かります。精度が上がったというより、精度を確認できるようになった、という表現が実態に近いです。 もうひとつ変わったのは、不具合が出たときに疑う先です。以前は確率的な出力そのものを相手にしていました。いまは、まずスクリプトを疑います。スクリプトは決定論的に動くため、同じ入力なら同じ結果が返ります。テストを書いて事前に潰すこともできます。網羅性は安定し、原因の切り分けも早くなりました。 レビューの仕方はどう変わったか HTMLにしたことで、レビュー用途に合わせた画面を用意できるようになりました。以前は根拠を確かめるたびに、ソースコードを別のウィンドウで開き、複数のドキュメントを探して画面を切り替えていました。いまはソースコードも含めて、必要なものが1枚に集まっています。それによりエディタを別で開く必要がなくなり、ドキュメントを探す時間が減りました。 POC段階で見えている限界 本取り組みはまだPOC段階です。 計算で担保できるのは、あくまで「漏れていないか」までです。書かれている内容が正しいかは、別の問題として残ります。コードの語彙を利用者から見た言葉へ翻訳する部分は推論の仕事です。その翻訳が妥当かどうかを機械で判定する方法は、まだありません。ここはいまも人による確認が必要です。 静的解析にも取りこぼしがあります。動的な呼び出しなどは決定論でたどれません。分析した画面には、例外なく不完全の印が立っています。印が立っている以上、網羅を主張できる範囲もそのぶん狭くなります。 今後の展望 この分析の先には、新しい環境の設計と実装、そして新旧を比べた合否の判定が控えています。 直近で進めるのは、分析資料を設計工程へつなぐことです。分析で得た仕様が、そのまま設計の入力として使える状態を目指しています。 新旧システムを比較して「同じ挙動と言えるかどうか」の合否判定は、検出できた差分についてしか下せません。つまり判定の信頼性には、その手前の抽出でどれだけ漏れなく差分を拾えているかで上限がかかります。網羅を計算で確かめられるようにしたねらいは、この信頼性の上限を引き上げることにあります。 抽出した仕様は、合否の判定にそのまま使える形で書いてあります。分析結果を人が読み替えて別の形に起こし直す、という一手間が省けます。 まとめ 本記事では、既存システムのAI分析を作り直した取り組みを紹介しました。計算で確定できることは計算に任せました。その結果へ識別子を振り、AIには引用を義務づけました。こうして、抜け漏れを計算で見つけられるようになりました。人間が追加調査で埋めていた部分も、ワークフローの中へ取り込みました。出力をHTMLにしたことと合わせて、確認のしやすさが変わっています。AIを使った既存システムの調査を検討している方がいれば、ぜひ参考にしてみてください。今後は、抽出した仕様を新旧の合否判定へそのままつないでいきたいと考えています。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、ECプラットフォーム部マイクロサービス戦略ブロックの半澤です。普段は、システムアーキテクチャの全体最適化など、モノリスからマイクロサービスへシステムをリプレイスする過程で生じる、複数チーム間の課題解決を主に担当しています。 本記事では、ZOZOの基幹システムリプレイスにおいてモノリスからマイクロサービスへ移行するために、イベントストーミングを起点にドメイン間の境界と責務の決定をどのように進めたかを紹介します。あわせて、既存コードの解析と付箋への変換に使ったClaude CodeのAgent Skillsの設計を、付録で共有します。なお、イベントストーミング(業務で起きる出来事を時系列に並べ、関係者で業務の流れを理解する手法)自体の詳しい解説は本記事の範囲外とし、実際にどう業務理解・境界検討に活用したかに絞って紹介します。 目次 はじめに 目次 基幹システムリプレイスについて 在庫ドメインについて 在庫ドメイン検討の進め方 1. 基幹の各チームリーダーへのヒアリングで在庫依存度を洗い出す 2. 業務理解のためのイベントストーミングを実施する 3. 既存コードを読みシーケンス図を作成する 4. シーケンス図を付箋形式に変換する Skillによる変換の仕組み Miroボード上での手動調整 付箋形式の利点 シーケンス図をそのまま使わない理由 ステップ2と別に変換し直す理由 イベントストーミング本来の使い方との距離 5. 基幹の各チームへのヒアリングでAs-Isを完成させる 6. To-Beを整理し、在庫ドメインの境界・責務を決定する 7. 他ドメインのチームと合意形成する まとめ さいごに 付録:使用したAgent Skillsの詳細 ステップ3:シーケンス図生成Skill(trace-vbs-flow) ステップ4:付箋変換Skill(seq-to-miro) 基幹システムリプレイスについて まずは、基幹システムリプレイスについて簡単に紹介します。ZOZOの基幹システムは、物流拠点ZOZOBASEにおける入荷・発送業務や、ZOZO社員やZOZOTOWN出店者様が利用するバックオフィス業務のためのシステム全般を指します。2004年から大きなアーキテクチャ変更なく積み重ねられてきたモノリシックなシステムで、その多くはVBScriptで書かれています。 Microsoftは2023年10月にVBScriptの段階的な非推奨化を発表しました。 公開されているタイムライン では、2027年頃にWindowsでのデフォルト無効化、その後の完全削除が見込まれています。基幹システムが動かなくなれば事業が止まるため、VBScriptからの脱却は事業継続のために必須かつ急務です。 また、現行システムはVBScript以外にも負債を抱えています。基幹DBが密結合であるため、基幹DBへのクエリがZOZOTOWNなど他システムの安定性に影響しています。さらに、使われていない機能も多く残り、プログラム構造が業務モデルを表現していないため、属人化も進んでいます。こうした課題もリプレイスで解消しようとしています。 これまでは、入荷・発送といった自明な業務のまとまりはあったものの、はっきりしたドメイン境界はありませんでした。テーブルをドメインごとに管理するのではなく、どの業務も同じ基幹DBのテーブルを自由に参照・更新しながら、業務・会計の整合性を担保してきました。この形は、テーブルごとのオーナーシップを厳密に分けるよりも、必要な案件に柔軟にリソースを集めて対応しやすく、モノリスとして基幹システムを運用してきたフェーズでは合理的でした。 一方で、システム規模の拡大により、全員がすべての仕様や変更を把握することは難しくなってきています。そのため、今後はドメインごとに責務を分け、各チームが自ドメインの専門性を高めながら開発・運用できる状態を目指しています。並行開発によるスピード向上と、自ドメインに集中することによる品質向上が狙いです。リプレイス後は、この方針に沿って、テーブルやカラムごとに各ドメインがオーナーとなり責務を持ちます。DBについては、まずは既存の基幹DBを使用したままモジュラモノリス構成を挟み、将来的にはドメインごとに分離して、他ドメインとはAPIまたはイベントで連携する想定です。 在庫ドメインについて 基幹システムリプレイスにおいて、現在想定されているドメインは次のとおりです。 入荷 発送 商品 注文(返品・交換を含む) 会計 認証・認可 など 会計や認証・認可ドメインは独立性が高く、比較的マイクロサービス化しやすい一方、入荷や発送、注文ドメインなどは在庫データと密に結合しています。そもそも在庫は、特定のドメインに属する機能というより、複数のドメインから参照・更新される「みんなのもの」になっています。在庫を切り離せない限り他ドメインも独立できない、という構造的な要のポジションにあるのです。 また、在庫データの実態を把握しているメンバーは、基幹システムの中でも限られた数人の古参メンバーに留まります。しかもその数人にとってすら、在庫の全体像がすべて見えているわけではありません。「なぜこの処理が存在するのか」「なぜ本番環境にこのような想定外のデータがあるのか」といった疑問に一人で答えられる人はいません。複数チームのリーダーへ横断的にヒアリングしてようやく実態が見えてくる、という状況でした。筆者自身も基幹システムの知識がほぼない状態からのスタートで、ドキュメントや特定の一人への質問だけでは実態を掴みきれず、複数人の記憶と実際のコードを突き合わせながら理解を組み立てていく必要がありました。 そのため在庫リプレイスでは、VBScriptからの脱却に加えて、属人化・ブラックボックス化を解消し、在庫データの品質を担保することを目指しています。また、在庫の責務を明確にして他ドメインとの結合を減らし、将来的に各ドメインが独立できる状態を作ることも重要です。 果たして「みんなのもの」である在庫は、1つのドメインとして成立するのでしょうか。また、成立するとしたら境界はどこに引くべきなのでしょうか。先行きが全く見えない中で、イベントストーミングを起点に境界を探ることにしました。 在庫ドメイン検討の進め方 在庫ドメインの検討は、約半年かけて次の7ステップで進めました。以降、各ステップの説明の中で前後のステップに言及することがあるため、先に全体像を示しておきます。 基幹の各チームリーダーへのヒアリングで在庫依存度を洗い出す 業務理解のためのイベントストーミングを実施する 既存コードを読みシーケンス図を作成する シーケンス図を付箋形式に変換する 基幹の各チームへのヒアリングでAs-Isを完成させる To-Beを整理し、在庫ドメインの境界・責務を決定する 他ドメインのチームと合意形成する 体制は少人数でした。検討を始めた時点では在庫チームはまだ存在せず、筆者がファシリテーターとして先行して着手しました。途中で決まった在庫チームのメンバーと筆者の3名を中心に、解析対象の領域に詳しいエンジニアの応援も得ながら進めました。 1. 基幹の各チームリーダーへのヒアリングで在庫依存度を洗い出す まずは、基幹の各チームリーダーを対象に、在庫リプレイス検討の目的を説明し、それぞれの担当領域における在庫への依存度をヒアリングしました。 ここで知りたいのは、在庫と同時に更新しなければ業務として成り立たないデータ、つまり強整合を必要とする範囲がどこまで広がっているかです。なお本記事では、更新の反映遅れを許容する結果整合性では業務が成り立たず、在庫と常に同時に正しくなければならない性質を「強整合」と呼びます。現在は同一DBのトランザクションがこれを自然に満たしていますが、将来DBをドメインごとに分離すると自明には満たせなくなるため、この範囲がドメイン境界を決めるうえでの重要な制約になります。ただし、その範囲はこの時点では分かりません。そこで、こちらから「在庫」に関係しそうな核となるテーブル・カラムを具体的に挙げ、それを更新しているユースケースを各チームにピックアップしてもらいました。 ヒアリングシートのイメージは、次のような形です(実際の記入内容は載せられないため、形式のみを示すサンプルです)。 チーム 依存先 参照のみ or 更新あり 在庫と同時に更新するデータ リアルタイム性の要否 主なユースケース 不整合時の業務影響 発送 倉庫在庫数 更新あり 発送 注文 入出庫履歴 必要 発送準備 発送完了 在庫不足エラーによる注文キャンセル 入荷 倉庫在庫数 更新あり 発注 入荷 入出庫履歴 必要 入荷検品 棚卸 - 会計 倉庫在庫数 参照のみ ー 不要 会計処理 財務諸表の正確性の低下 このように担当領域ごとに整理することで、「リアルタイム性や同時更新が必要な領域」の当たりをつけ、次のステップで詳しく見るべき対象を絞り込みました。この時点で挙がった在庫更新のユースケースは合計で約30個です。これらのユースケースを「入荷検品をした」のような出来事(イベント)の形へ変換し、時系列に並べて業務全体を俯瞰するBig Pictureとして整理しました。次のステップで実施するイベントストーミングの土台になります。 実際のボードは次のような形です(内容はぼかしています)。 2. 業務理解のためのイベントストーミングを実施する このステップでは、ステップ1で更新ありかつリアルタイム性が必要と回答のあったチームを対象に、業務理解を主な目的としてイベントストーミングを実施します。具体的には、Big Pictureに並べた在庫操作イベントを起点に、その前後の業務イベントを洗い出して補い、業務の流れを詳細化するProcess Modellingまで掘り下げます。 誰が何のためにどのような業務の流れで在庫を操作し、その結果何が起こるのか。 在庫の状態にはどのようなパターンがあるのか。 こうした点を整理しながら、この後のステップのために既存コードとの紐づきもヒアリングしておきます。実施形式はチームごとのオフライン開催で、業務をよく知るエンジニア2〜3名に参加してもらい、半日程度で行いました。 イベントストーミングでは、業務の流れを種別ごとに色分けした付箋で表現します。付箋の凡例は次のとおりです。 この凡例をもとに実施したボードの一部は、次のような形です。赤いホットスポットが多く、この時点ではまだ多くの疑問が残っていることが分かります。 3. 既存コードを読みシーケンス図を作成する ステップ2で紐づけた既存コードから、シーケンス図を作成します。主な目的は、使用しているDB・テーブルの洗い出しです。特に、在庫テーブルの操作を含むトランザクションで他に操作されているテーブルと、在庫テーブルの更新パターン・不変条件を整理します。 既存コードには、いわゆるソフトウェアアーキテクチャが明確に適用されていません。ASP(VBScript)とDBというレイヤの分割はあるものの、業務ロジックのほとんどはストアドプロシージャに集中しており、業務のルールがSQLの羅列として表現されています。そのため、DBへの操作を追うことが、そのまま業務ロジックの把握につながるのです。加えて、リプレイス後はテーブルごとに各ドメインがオーナーとなるため、どのテーブルをどう操作しているかは境界を引く直接の判断材料になります。 また、シーケンス図はファイルを追うだけでは掴みにくい全体像の把握にも役立ちます。ルートとなるASPファイルにすべての処理が平坦に書かれているケースは少なく、同じファイル内の別のFunctionや別ファイルのFunctionを次々と呼び出します。さらに、ストアドプロシージャも何層にもネストしており、処理を追ってファイルを行き来するうちに、どこに何が書かれていたか分からなくなってしまうのです。呼び出しフローを1枚の図に落とし込んでおけば、どこにどのような処理があるかを俯瞰できる地図になります。 シーケンス図の作成には、Claude CodeのAgent Skillsを使用します。Skillには、ルートとなるFunctionから呼び出しフローを静的解析するルールを定義しています。あわせて、ドメインごとの「辞書」(テーブル一覧とユビキタス言語のマッピング)も用意し、Skillに読み込ませています。これまではテーブル名をそのまま呼んだり、同じテーブルに対して複数の呼び名が混在したりしていたため、今回選定したユビキタス言語を意識的に使う狙いです。Skillの詳細な設定は、興味のある方向けに本記事末尾の付録で紹介します。 生成されたシーケンス図では、トランザクション境界がactivateで表現され、更新処理の矢印は強調色で描かれ、在庫ドメインのテーブル更新にはnoteでハイライトが付きます。また、ストアドプロシージャの処理はgroupで囲み、その範囲を分かりやすくします。 次の例では、在庫テーブルの更新と同一トランザクション内で、注文や入荷といった他ドメインのテーブルも更新されている様子が読み取れます。なお、本記事に登場するテーブル名・カラム名・コード値は、いずれも一般的な命名に置き換えたサンプルです。また、以降に登場する「倉庫棚」は在庫データの呼び名です。商品1点(1ピース)ごとに1レコードを持ち、その商品のバーコードや、実際に保管されている倉庫・棚の番号を管理しています。 生成された図はそのまま正とはせず、出力にコメントで付与された根拠(ファイルパス・行番号)をたどってコードと突き合わせてレビューしています。Skillはあくまで、解析の下書きを高速に得るための道具という位置づけです。 4. シーケンス図を付箋形式に変換する ステップ3で作成したシーケンス図を、付箋としてMiroボード上に展開します。ここで作るのはイベントストーミングそのものではありません。付箋・時系列という表現とその語彙をイベントストーミングから借りて、CRUD単位の更新処理を並べた本ワーク独自の表現です。イベントストーミングとの違いは、このステップの最後にまとめて説明します。 Skillによる変換の仕組み この変換には専用のSkill(seq-to-miro)を使用します。シーケンス図のファイルパスとMiroボードURLを入力に、Miro MCPツールで座標計算済みの付箋・矢印をボード上に直接生成します。 Skillは、シーケンス図中のSQLから更新処理を抽出し、「コマンド」「集約」「ドメインイベント」の付箋へ機械的に変換します。また、更新処理を囲む条件があれば「ポリシー」として出力し、更新対象のテーブル名もイベントの下方へ出力します。テーブル名は、既存ソースとの紐づけのために存在する本ワーク独自の付箋種別です。テーブル・カラム名の日本語化には専用の辞書を使います。Skillの詳細な設定は、こちらも本記事末尾の付録にまとめています。 たとえば、ステップ3のシーケンス図例にあった「倉庫棚の新規作成」処理は、次の付箋に変換されます。 INSERT stock_shelves (warehouse_id=9) → [コマンド] 作成する [集約] 倉庫棚 [ドメインイベント] 作成した [テーブル] stock_shelves [ホットスポット] 在庫ドメイン更新: - 倉庫棚の新規作成 warehouse_id=9 シーケンス図全体の出力例は以下のとおりです。同じ形の塊が横に並ぶ全体像を掴むためのもので、文字は読めなくて構いません。 Miroボード上での手動調整 出力はあくまで機械的な一次変換と位置づけ、Miro上で内容の確認と手動による調整をします。複数テーブルの操作が1つの業務的な意味にまとめられそうな場合は、手作業で1つにまとめます。たとえば、returnsとreturn_detailsへのINSERTは「返品を作成した」という1つのまとまりにする、といった具合です。 次の例では、赤い背景が変更の対象とする範囲です。上段が変換直後の状態で、返品と返品明細が別々の付箋列として横に並んでいます。下段では、これを「返品を作成した」という1つの付箋列へ統合し、returnsとreturn_detailsのテーブル付箋をその下へまとめています。 付箋形式の利点 先ほどの機械的な変換だけでも、1つの更新処理は「何に対して(集約)」「何を要求し(コマンド)」「何が起こったのか(ドメインイベント)」という付箋の塊にまとまっています。その塊だけを見れば、処理の内容が一目で分かります。SELECT処理を省き、条件は必要最小限のポリシーとして残して更新の事実に絞るぶん情報量が少なく、その場で処理の流れを理解し議論するのに必要な粒度まで単純化できるのが利点です。 さらに、複数の更新を1つの業務的なまとまりへ手動で統合しておけば、SQL単位で追うよりも粗い、業務の単位で処理の流れを追えるようになります。DBへの操作を追うだけでは、その処理が業務上どのような意図・目的で行われているのかまでは分かりません。そのため、この時点での業務的な処理のまとまりはあくまで在庫チーム側の仮説にすぎず、 warehouse_id = 9 のようなコード値が何を指すかまでは在庫チームだけでは読み解けません。この仮説を基幹の各チームに見せてその場で確認できることが、次のステップの効率を高めます。 もう1つの利点は、複数人でリアルタイムに修正しやすいことです。付箋は1枚ずつが独立した単位なので、ステップ5のAs-Is確認での指摘反映も、ステップ6のTo-Be整理での意味のあるまとまりへの移動・統合・分割も、その場のマウス操作で完結します。こうした理由から、ステップ5以降の基幹の各チームとの議論では主にMiroボード上の付箋を使います。シーケンス図は処理の詳細を確認したくなったときに立ち返る参照先、という役割分担です。 シーケンス図をそのまま使わない理由 シーケンス図をそのまま整理に使わなかったのは、実際のコードから書き起こした図が、筆者自身が読み解くにも、基幹の各チームとの議論の場に持ち込むにも大きすぎたためです。最大のものではPlantUMLのソースが3,000行を超え、更新系SQLだけで約150箇所、更新対象のテーブルは40を超えます。 この規模になると、1つの処理を理解するために必要な情報が図全体に分散し、参照系も含めた大量の処理と条件分岐の中から前後関係を掴むのが困難になります。画面上でも、全体が見える倍率では文字が読めず、文字が読める倍率では全体が見えないため、拡大・縮小とスクロールを繰り返す読み方を、ステップ5以降では基幹の各チームにも強いてしまいます。 そのため、今回はイベントストーミングの記法を借りた付箋での表現を採用しました。対象の規模がもっと小さければ、シーケンス図のまま進められる場面もあるでしょう。 ステップ2と別に変換し直す理由 ステップ2でもイベントストーミングを実施していますが、目的が異なります。ステップ2は、基幹の各チームの記憶をもとに、業務イベントの粒度で業務を理解することが目的でした。しかし業務イベントの粒度では、複数ドメインにまたがる更新の裏側までは見えません。たとえば、ステップ2で「在庫を登録した」と表現されていたある処理は、コードを読むと次のテーブル更新で構成されていました。 発注データを更新した(担当候補:商品) 入荷データを作成した(担当候補:入荷) 在庫管理用バーコードを発行した(担当候補:在庫) 倉庫棚を作成した(担当候補:在庫) 入出庫履歴を作成した(担当候補:在庫) 作成した倉庫棚の評価額を更新した(担当候補:会計) 作成した倉庫棚の販売価格と販売開始・終了日時を更新した(担当候補:商品) 1枚の業務イベントの付箋の裏に、商品・入荷・在庫・会計にまたがる更新が同一トランザクションで詰まっていたことが、ここで初めて見える形になります。リプレイスの検討で知りたいのは、まさにこの「どこに、どのドメインの責務が混ざっているのか」です。そこでこのステップでは、コードで確認した事実をDB更新の粒度で同じMiroボード上に書き出し、ステップ2の業務イベントと突き合わせながら、見えていなかった処理を追加していきます。 イベントストーミング本来の使い方との距離 ここまで読んで、イベントストーミングに詳しい方ほど違和感を持つかもしれません。一般的なイベントストーミングでは、システム詳細やテーブル操作に踏み込みすぎず、業務イベントにフォーカスすることが大切だとされています。CRUDの粒度に引きずられると、現行実装の都合をそのまま未来のドメインモデルへ持ち込んでしまう危険があるためです。 ステップ1・2では、それぞれ業務全体を俯瞰するBig Pictureと個々の業務の流れを見るProcess Modellingで業務を整理しました。ステップ4以降で行っている付箋化は、イベントストーミングそのものではありません。DB更新の粒度で付箋化するのは、後のステップでTo-Beも同じ粒度で描き、As-Isとの差分から「どの更新を、どこへ移すか」というTODOを洗い出すためです。ここで得ようとしているのは、将来のドメインモデルそのものではなく、既存コードの事実とそこからの移行先を同じ物差しで並べる地図なのです。 本来の意味と本ワークでの使い方の対応は、次のとおりです。 付箋種別 イベントストーミングでの本来の意味 本ワークでの使い方 コマンド 何かを起こしたいという意思決定。システムへ送られる(送っただけでは完了を意味しない) SQL更新文を命令形で表したもの ドメインイベント コマンドの結果として起こった、ドメインエキスパートにとって意味のある事実(過去形・変更不可) テーブル更新が行われたという事実(CRUD粒度) 集約 イベントとコマンドの論理的なグループであり、ビジネスプロセスの1ユニット 更新対象をユビキタス言語で表した名前 ポリシー イベントをきっかけに暗黙・明示的に行われるリアクション(自動化されているとは限らず、手動の場合もある) 更新処理を囲む条件分岐( alt / opt )の条件文をそのまま転記したもの テーブル ―(本ワーク独自の種別) 原文へのトレーサビリティ用に残す物理テーブル名 ホットスポット 矛盾・疑問点・意見の相違など、その場で解決できない論点を示すマーカー 本来の意味に加えて、機械変換時に付与するハイライト注記の受け皿としても使用 特にテーブルは、アクター(業務を担う人やシステムなど)が次の意思決定をするために参照する付箋種別であるリードモデルと役割を混同しやすいため、イベントストーミングの用語を流用せず独自の種別としています。 このように、機械変換した直後の付箋が表しているのは、まだ「テーブルを更新した」というコード上の事実にすぎません。次のステップで基幹の各チームによる業務的な意味づけを経て、本来の意味でのドメインイベントに近い表現へ育てていきます。 ステップ3・4は在庫チーム単独の作業で、次のステップからは基幹の各チームとの共同作業になります。 5. 基幹の各チームへのヒアリングでAs-Isを完成させる ステップ4でMiro上に展開した付箋をもとに、ステップ2のイベントストーミングと同じ参加者(基幹の各チームのエンジニア)へヒアリングし、As-Isを完成させます。ステップ2では記憶をもとに業務イベントを教えてもらいましたが、このステップでは逆に、コードから書き起こした事実へ業務的な意味づけをしてもらいます。確認するのは、それぞれの処理が業務的な意味を持つのか、それとも歴史的経緯で残っているだけでリファクタリングによって削除・整理すべきなのか、という点です。本質的な処理と実装都合の処理を、ここで仕分けていきます。このステップ以降のヒアリングは、各チームとのオンラインでの打ち合わせを重ねる形で進めました。チームによって異なりますが、多い場合はステップ7までに10時間弱かかりました。 たとえば、シーケンス図中の INSERT stock_shelves (warehouse_id = 9) は、「倉庫棚を作成する・作成した」というシンプルな付箋に変換されています。このままでは、在庫チーム側では何のための処理か読み取れません。 warehouse_id の 9 が何を指すのかも、マスタを引かなければ分かりません。これを基幹の各チームへ確認したところ、 warehouse_id = 9 は「ユーザーからの返送品専用の倉庫棚」であることが判明しました。そのため、付箋を「返送品専用の倉庫棚」(集約)に対して「ユーザーからの返送品の入庫を要求する」(コマンド)、「ユーザーからの返送品が入庫された」(ドメインイベント)に書き換えました。このように、「stock_shelves」(テーブル)と紐づいたまま業務的な意味が加わります。 次に、在庫ドメインの核である倉庫棚の作成処理が、入荷ドメインに属することが確実な入荷テーブル・入荷明細テーブルの作成処理の間に挟まれている点をヒアリングしました。倉庫棚の作成処理を切り出して、入荷テーブル・入荷明細テーブルの作成処理を1つにまとめられないか確認する意図です。しかしヒアリングする中で、そもそもこの入荷データはZOZOTOWNに出店しているブランド様から送られてくる商品のためのテーブルだと判明しました。ユーザーからの返送品には、本質的に不要なデータだったのです。そのため、「本質的には不要のため処理を削除する」というホットスポットを追加しました。同じ要領で、念のため行われている保険的なデータ作成や、既存処理を再利用するために挟まれた一時的な状態遷移も、削除・整理の候補として浮かび上がりました。 As-Isの完成形は次のとおりです。 あわせて、在庫テーブルを含むトランザクションで他に操作されているテーブルを、次の2つに分類するためのヒアリングも実施しました。 強整合が必要な集合(同時に正しくないと困る) たまたま同一トランザクションなだけの集合(歴史的理由・実装都合) 6. To-Beを整理し、在庫ドメインの境界・責務を決定する 完成したAs-Isをもとに、To-Beを描きます。 As-Isの付箋列では、ドメインに関係なくテーブル更新が入り乱れています。これをできるだけドメインごとに意味のある処理のまとまりへ整理し直し、まとまりごとに主体ドメインと依存先ドメインへ分けていきます。このまとまりの単位は、将来マイクロサービス化した際の1つのAPIに相当する粒度を意識しています。まとまりの単位が将来のドメイン間の呼び出し単位と一致していれば、主体・依存先を分けた結果をそのままインタフェース設計の出発点として使えるためです。この粒度で整理しておくことで、マイクロサービス化できるかどうかの判断もしやすくなります。この整理の結果として、次の2点が決まります。 在庫ドメインに置くテーブル・カラム 在庫ドメインが担う具体的な処理(業務を丸ごとなのか、一部なのか) ステップ5で作成したAs-Isをもとに整理したTo-Beは、次のような形になります。主体ドメインのフローと依存先ドメインのまとまりへ分かれています。 在庫ドメインで管理するテーブルと、他ドメインに属するテーブルは、この時点ですでにいくつか切り分けられていました。一方で、他のドメインが主体と思われる処理のまとまりの多くは、在庫に置くと決めたテーブルを同一トランザクションで更新しています。 そこで、「同一トランザクションを維持したまま、これらの処理のまとまりごと在庫が主体として引き取ればよいのでは」という選択肢も検討しました。引き取ったテーブルは在庫ドメインの所有になるため、将来DBをドメインごとに分離した後も、これまでどおりDBトランザクションで整合性を守れます。分散トランザクションや結果整合性の仕組みを新たに作り込む必要がなく、実装コストを低く抑えられる、一見合理的な案です。 この案を検証するため、倉庫在庫数と同一トランザクションで更新されているテーブルを関連図として書き出しました。すると、引き取り候補となる発送のテーブル自身もまた、発送の別のテーブルと同一トランザクションで更新されていることが分かりました。トランザクションを保つことを理由にテーブルを1つ引き取れば、次は同じ理由でその隣のテーブルが境界の内側に入ってきます。この連鎖の行き着く先は、在庫が発送の業務テーブル一式を丸ごと飲み込んだ姿、つまり「在庫=発送」です。そして同じことが、注文などほかのドメインでも起こります。 では、この連鎖を同一トランザクションが途切れるところまでたどり続けると、どこに行き着くのでしょうか。基幹DBのすべてのテーブルを洗い出したわけではありませんが、関連図に現れた発送・注文・入荷・商品のテーブルは、いずれも同時更新の関係で密につながっていました。その終着点はほぼ基幹DB全体、つまり今回脱却しようとしているモノリスの構造そのものです。実装のしやすさを優先して境界を広げる判断は、1回だけを見れば小さな妥協ですが、同じ判断を繰り返した論理的な帰結はモノリスへの逆戻りでした。ドメインごとにテーブルの責務を分けるはずが、境界を安易に広げると元の状態に戻ってしまうのです。 もう1つ、変更のしやすさという観点でも、この引き取り案は避けたいと考えました。仮に同時更新の連鎖をどこかで断ち切り、業務テーブルの一部だけを引き取る形で境界を引けたとしても、今度は在庫ドメインの変更頻度が上がってしまうためです。 在庫自体の操作は、入庫・出庫・引き当てといった少数の決まったパターンに集約されています。実際、先ほどの関連図には多くの業務テーブルが登場しますが、それらが行う在庫の操作は、この少数のパターンのいずれかに行き着きます。在庫の責務をこの決まった操作にとどめておけば、操作は業務を問わず再利用できる部品になります。各ドメインで業務が増えても、既存の操作を呼び出す側の対応だけで済み、在庫側の変更は発生しない想定です。ところが、他ドメインの業務テーブルまで引き取ると話が変わります。引き取った業務の処理はその業務専用のものとなり、再利用が利きません。業務が増えたり変わったりするたびに、本来他ドメインの関心事であるはずの対応が在庫側で必要になり、取り込んだ業務の数だけ在庫自体の変更頻度が上がっていくのです。しかも、その業務の仕様を一番よく知っているのは、本来その業務を担当するドメインのチームです。業務が変わるたびに在庫チームを経由した開発が必要になる体制は、各チームが自ドメインに集中して並行開発するというリプレイスの狙いに逆行します。複数ドメインの業務都合が同居する在庫は、「みんなのもの」である今の在庫の構造を、ドメインの内側へ再現した姿にほかなりません。 とはいえ、境界を最小にとどめられるかどうかは、強整合を必要とする範囲がどこまで広がっているか次第です。強整合が必要なテーブルであれば、境界の内側へ引き取らざるを得ないためです。そこで、ステップ5で仕分けた「強整合が必要な集合」を確認すると、それらは在庫の核となるテーブルの周辺に小さくまとまっており、他ドメインへ芋づる式に広がることはありませんでした。同一トランザクションで更新されている他ドメインのテーブルの多くは、同時に正しくなければ業務が破綻するわけではありませんでした。同じトランザクションが使えるので、一緒に更新しておくほうが楽だという理由で同居していたのです。境界を最小にとどめても、強整合の要件が壊れる心配はないと確認できました。 そのため、処理のまとまりを丸ごと持つのではなく、在庫は核となるテーブル・カラムと、強整合が必要なものだけを引き受ける形にとどめました。テーブルだけでなくカラムと書いたのは、境界がテーブルの内側を通る箇所があるためです。たとえば倉庫在庫数を持つテーブルには、商品の販売価格や会計の評価額といった、他ドメインの関心事にあたるカラムも同居していました。こうしたテーブルは丸ごとどちらかのドメインへ寄せるのではなく、カラム単位で在庫に置くもの・他ドメインへ移すものを切り分けています。また、特に整合性が重要なのは、倉庫在庫数と引き当て済み数のように在庫数を構成するカラム同士の関係で、これは核の内側に閉じていました。 基幹システムリプレイスでは、マイクロサービス間の依存を一方向に保つため、サービスをレイヤ状に配置し、上位から下位のレイヤへの依存だけを許すルールを設けています。将来のマイクロサービス化をしやすくするため、ドメイン間の依存関係も現時点からこのルールに沿って整理しています。このレイヤ構成に当てはめると、在庫は、多くのユースケースから利用されつつ他ドメインの領域には踏み込まない、最下層のレイヤという位置づけに収まりました。 ただし最下層のレイヤといっても、他ドメインの指示どおりにテーブル操作を代行するだけのCRUDサービスにする意図はありません。在庫ドメインが持つのは、在庫数と、その変動の履歴・理由を表す核となるテーブル・カラム群です。それらに対する入庫・出庫、在庫の引き当てとそのキャンセルといった処理のまとまりを責務として持ち、先ほど触れたカラム同士の関係や、在庫数と入出庫履歴の整合性といった不変条件は在庫自身が守ります。この責務の範囲では、これまで複数ドメインの処理に散らばりブラックボックス化していた在庫の仕様を、在庫ドメインのモデルとして表現し直します。他ドメインが在庫テーブルを直接更新する密結合をやめ、在庫の提供するインタフェースを介した操作に集約することで、冒頭に挙げた属人化・ブラックボックス化の解消にもつなげます。そのうえで責務を最小限にとどめたのは、在庫の範囲を広げるほど他ドメインが在庫から切り離せなくなり、「在庫を切り離せない限り他ドメインも独立できない」という構造を解消できなくなるためです。 この判定をユースケースごとに積み重ねた結果を、ドメイン間の依存関係図として可視化しています。矢印は「元のドメインが先のドメインに依存する」ことを表します。ここでの依存は、同じデータを参照することによるデータの依存ではなく、元のドメインが処理の中で先のドメインへ同期リクエストを送り、更新を依頼するという処理の依存です。そのため、会計のように参照のみの依存はこの図に含めていません。 今回引いた境界は、強整合が必要な集合を丸ごと内側に含んでいます。その帰結として、ドメイン境界をまたいで強整合が必要な箇所は残らず、図の矢印が示す他ドメインとの間は結果整合性で成立します。そのためTo-Beでは、できるだけ結果整合性に倒す方針です。境界をまたぐ処理が失敗した場合も、完了済みの処理を補償処理で巻き戻すのではなく、失敗した処理をリトライして完了へ進めます。 一方で、DBがドメインごとに分離され同一トランザクションが使えなくなる将来に、結果整合性の前提となるこの「リトライで完了へ進める」をどのような仕組みで担保するのかは、この時点では決めていません。恒久的に完了できない処理をどう扱うかも同様です。まずはモジュラモノリス構成で既存のトランザクションを維持したまま、処理のまとまりを今回引いた境界で切れる形へ徐々に近づけていき、境界に沿ったまとまりができてから具体的な実現方式を検討する計画です。 意外な発見もありました。独立性が高くマイクロサービス化しやすいと考えていた会計も、As-Isを追うと、評価額登録など会計のための処理が業務システム側のロジック中に存在していることが分かったのです。 また、実際のモノの動きを伴わない、会計処理のためだけの入出庫履歴を作成している処理も見つかりました。会計処理が入出庫履歴を入力として組み立てられているため、便宜的に入出庫履歴を作成して会計処理へつなげる実装になっていたのです。 つまり、入荷・発送・在庫管理といった業務システム側が「会計処理を成立させるためにどのようなデータを作るべきか」という会計側の知識を抱え込んでいました。これらの入出庫履歴はデータ上で実際の入出庫と判別できるため、会計処理として問題があるわけではありません。しかし、モノが動いていない入出庫履歴を作り続けた結果、入出庫履歴は実質的に会計のためのデータとなり、実際のモノの動きを表す記録という本来の意味が薄れていました。この結合は業務システム側のリファクタリングだけでは剥がせないため、会計側の計上ルールの見直しとあわせて解消していく方針です。今回決定した在庫の責務は、この見直しによって会計の事情が在庫から取り除かれ、入出庫履歴が実際のモノの動きの記録に戻ることを前提にしています。 こうした構造は、ドメインを切り出そうとする今のフェーズでは剥がすべき結合です。その所在は、テーブル更新の粒度でAs-Isを追って初めて具体的に見えてきました。 7. 他ドメインのチームと合意形成する 最後に、As-IsとTo-Beの差分から、「To-Beに到達するためには、どの処理をどう変更・リファクタリングする必要があるのか」というTODOを洗い出します。このTODOを、過渡期戦略(リファクタリングや、To-Beへ到達するまでのステップ)とあわせて資料にまとめ、他ドメインのチームと確認しました。 合意の対象は、To-BeとTODOの両方です。両方を対象にしたのは、To-Beだけでは合意に現実味が出ないためです。 現在、在庫の更新処理はそのほとんどがストアドプロシージャの中に埋まっています。ここからドメイン境界に沿った形へ組み替えるには、ストアドプロシージャの分解が必要で、相応の時間がかかります。加えて、優先すべきはVBScriptの廃止です。まずは各チームが、既存の業務・処理の構造を保ったままVBScriptを新しい言語へ置き換える等価リプレイスを進めます。等価リプレイスでは、発送・入荷といった自明な業務単位のAPIをモジュラモノリス上に作成しますが、DBは既存の基幹DBを共有したままで、テーブルにドメインの境界は入りません。等価リプレイスの取り組みについては、 本番環境における等価比較を活用した言語リプレイス の記事で紹介しています。あわせて、既存システムにはなかったユニットテストも充実させます。ドメイン分割のためのストアドプロシージャ分解に着手できるのは、その後です。 つまりTo-Beは、何年先に到達するのか現時点では見通せません。到達時期の分からない理想形だけを見せて合意を求めても、各チームにとっては自分たちの計画へ落とせない絵に留まります。そこで、To-Beに合意したうえで、そこへ向かって次に何を進めるべきかをTODOとして洗い出し、優先度と着手時期まで含めて認識を揃えました。TODOを互いに確認できた時点をもって、合意としています。 もっとも、すんなり合意できたものばかりではありません。たとえば前述の「会計処理のためだけの入出庫履歴」をやめる方針は、会計ポリシーの見直しを伴うため、経理・監査を含む関係者や基幹システムの責任者への確認が必要で、採用の判断には時間を要しました。それでも、無駄な処理の多い現状と変更後の保守のしやすさは明白だったため、目指す方向性として合意でき、全チームから「自チームにとってもメリットがある」という回答を得られています。対応は、重要度の高い部分のみスケジュールを決めて優先的に実施し、残りは安全性を第一に徐々にリファクタリングしていく計画です。「この形の処理を今後は増やさない」という方針を共有できたこと自体にも、負債の再生産を防ぐ効果があると考えています。 今回の取り組みは、このステップ7まで完了しています。 まとめ 本記事では、モノリシックで密結合な基幹システムをドメイン分割するために、在庫ドメインが成立するのかどうかを検討し、その境界と責務を決定するまでの事例を紹介しました。 DB更新の粒度で検討したことにより、理想形(To-Be)へ至るリファクタリングや進め方を、複数チーム間で同じイメージを持って描けました。また、数人の記憶の中にしかなかった在庫の全体像が、コードと突き合わせた付箋・図・資料の形で蓄積され始めたことも、属人化の解消へ向けた一歩になっています。 冒頭の問いへ立ち返ると、「みんなのもの」だった在庫は、責務を最小限に絞れば1つのドメインとして成立する、という結論に至りました。ただし、これが必ず正解とは限りません。まだ見えていない部分もあるかもしれません。今後は、まずモジュラモノリスの1モジュールとして出発し、必要に応じて境界を見直していく予定です。 さいごに 筆者にとって、基幹システムの案件は今回が初めてでした。新鮮だったのは、ユーザーとの距離の近さです。基幹システムのユーザーは倉庫や社内にいて、ECサイトの開発よりも使う人の顔が見えます。距離が近いからこそ、システムの機能が足りない部分を運用の工夫で乗り越えている現場の実情も聞けました。その実情を踏まえて、As-Is・To-Beの検討では「こういう作りにすれば、そうした現場の課題も解消できるのでは」という議論をたくさんしました。正直なところ、「在庫ドメインをマイクロサービス化する」という目的だけで意味を見出し続けるのは難しいものです。それでも、このリプレイスの先には、現場の業務負担が軽くなり、開発のスピードと品質も間違いなく向上する未来が待っています。その未来で喜ぶ人の顔を想像することは、これから実際にリプレイスを進めていくうえで、何よりの原動力になるはずです。 試行錯誤の記録ではありますが、本記事が、モノリシックなシステムのドメイン分割に取り組む方々にとって、少しでも参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com 付録:使用したAgent Skillsの詳細 ここからは、ステップ3・4で使用したAgent Skillsの設定内容を紹介します。ツールの実装に興味がある方向けの補足なので、不要な方は読み飛ばしていただいて構いません。 ステップ3:シーケンス図生成Skill(trace-vbs-flow) Skillには、ルートとなるFunctionから呼び出しフローを静的解析してPlantUML形式のシーケンス図を生成するための解析ルールをMarkdownで記述しています。トランザクション境界を必ず明示すること、UPDATEはテーブル名と更新カラム一覧を必ず記載すること、解析できなかった箇所は推測で補完せず明示することなどをルール化しています。また、対象ドメインのテーブルに触れる箇所を色で強調し、長いシーケンス図の中からドメインの関与箇所を一目で拾えるようにしています。大きなファイルについては、1セッションで解析を完結させず、解析プランを提示したうえでFunction・ストアドプロシージャ単位に分割して進めることもルールとして定めています。以下は、Skill定義から中核のルールを抜粋したものです(社内固有の表現は一部抽象化し、テーブル名・カラム名は一般的な命名に置き換えています)。 --- name : trace-vbs-flow description : > ASP(VBScript)の処理フローを静的解析し、トランザクション境界を 明示したPlantUMLシーケンス図と使用テーブル一覧表を生成する。 --- # Domain Parameter スキル実行時にドメイン定義ファイル (ドメインのテーブル一覧とユビキタス言語の辞書)を読み込む。 # 操作の記載ルール(抜粋) * UPDATE はテーブル名と更新カラム一覧を必ず記載する。 カラムが特定できない場合は UNKNOWN _ COLUMNS と明示する # ドメインテーブルのハイライト(抜粋) * UPDATE / INSERT / DELETE の矢印はすべて赤(#tomato)で強調する * トランザクションの活性区間は、対象ドメインのテーブル更新を 含むか否かで色を分ける(含む: #00bfff / 含まない: #c0c0c0) * 対象ドメインテーブルへの操作は note で強調する (更新: #FFD24D / 参照: #FFEB99) # ハルシネーション防止ポリシー(Evidence-first) * 出力に確度を付与する: [CONFIRMED] / [INFERRED] / [UNRESOLVED] * 根拠(ファイルパス・行番号・抽出SQL)をコメントで出力する * 見つからないストアドプロシージャの処理内容、未発見Functionの中身、 特定不能なUPDATEカラムは推測で補完しない # ファイルサイズが大きい場合 * 1セッションで完結させようとしてはならない * 最初に解析プランを提示し、Function・ストアドプロシージャ単位に 分解して逐次実行し、各ステップ完了後に次の実行可否を確認する また、ドメインごとに「辞書」にあたるドメイン定義ファイルを用意し、Skillから読み込ませています。ドメインに属するテーブルの一覧と、テーブル・カラム名をユビキタス言語へ変換するマッピングを定義しています。あわせて、コード値を業務上の名称に解決するためのマスタデータも辞書として持たせています。 # Domain Tables 以下を在庫ドメイン対象とする: * stock _ shelves # Ubiquitous Language 補足説明では必ずユビキタス言語を使用する: | テーブル・カラム | 意味 | | --- | --- | | stock _ shelves | 倉庫棚 | | stock _ shelves.quantity | 倉庫在庫数 | | stock _ shelves.allocated _ quantity | 引き当て済み数 | ステップ4:付箋変換Skill(seq-to-miro) Skillの中心は、シーケンス図中のSQL更新文と条件分岐を付箋列へ機械的に変換するルールです。付箋の種別は「Command(命令形)」「Aggregate」「Event(過去形)」「Policy」「Table(物理テーブル名)」「HotSpot(注記)」の6つです。テーブル・カラム名の日本語化には辞書を使い、辞書にない単語が出てきた場合はユーザーに確認して辞書に追記し、語彙を蓄積していきます。以下は、Skill定義の主要なルールを抜粋したものです。 --- name : seq-to-miro description : > PlantUMLシーケンス図とMiroボードURLを受け取り、イベントストーミングの 記法を借りた付箋・矢印を座標計算済みでMiroボード上に直接生成する。 --- # SQL動詞・条件分岐の変換 | シーケンス図 | 付箋 | | --- | --- | | INSERT | [Command]作成する → [Event]作成した | | UPDATE(カラムあり) | [Command]日本語(カラム名)を更新する → [Event]…を更新した | | DELETE | [Command]削除する → [Event]削除した | | alt / opt の条件文 | [Policy] 条件文をそのまま転記(翻訳しない) | | note(注記) | [HotSpot] 注記の内容をそのまま転記 | # テーブル単位への分割 * 1つのSQL文が複数テーブルへの操作なら、テーブル単位で付箋列を分割する 例: INSERT returns, return _ details → 「返品を作成した」「返品明細を作成した」の2列に分ける * 複数の付箋列を1つの業務的なまとまりへ統合する判断はSkillでは行わず、 人間がMiroボード上で行う # カラム名の日本語化(辞書の仕組み) 以下の優先順で辞書ファイルを引く: 1. per-column 上書き辞書(テーブル.カラム → 日本語) 2. 概念表(カラム名を単語に分解し、単語ごとに日本語化して結合) 出力は必ず「日本語(カラム名)」形式にし、原文へのトレーサビリティ 確保のため英カラム名を括弧書きで残す。 # Miroレイアウトルール(抜粋) * 1つの更新処理は Command → Aggregate → Event を横一列に並べ、 テーブル名・注記の付箋はEventの下へ縦に積む * 座標は付箋同士は+300px、次の処理との間は+400pxの足し算で決定する # ユーザーへの確認(human-in-the-loop) * アクターとフロー起点のCommandはSQLから導出できないため、 タイトル・ファイル名からの推測をデフォルト選択肢として提示し、 ユーザーに確認する * 未知語の確認は都度ではなく、最初にまとめて洗い出して 1〜2回の質問で完結させる # ハルシネーション防止 * 推測で訳を作らない。辞書にない単語は必ずユーザーに確認して辞書に追記する * SQLに書かれていないカラムを Command / Event に書かない * シーケンス図にない条件・ループを追加しない
はじめに こんにちは。Developer Engagementブロックの @wiroha です。8月21日(金)に、ZOZOにて中高生女子を対象とした体験イベント「 ZOZOTOWN・WEARを支える技術と働き方を知ろう! 」を開催しました。 これは 公益財団法人山田進太郎D&I財団 が実施する「 Girls Meet STEM 」プログラムの一環です。中高生女子がSTEM(科学・技術・工学・数学)分野で働く人やSTEM分野で学ぶ学生、実際の現場に触れることで、将来の可能性を広げる機会を提供することを目的としています。ZOZOではこの活動の意義に共感し2024年より参画しており、今回は4度目の開催です。 今回は21名の参加者が集まり、オフィスツアー、サービス体験&技術紹介、女性エンジニアとの交流を通じて、ファッションと技術の面白さを体感しました。本記事では、当日の様子をご紹介します。 イベント概要 日時:2026年8月21日(金)13:00~15:30 会場:ZOZO西千葉本社 対象:中学1年生~高校3年生までの戸籍上または性自認が女性の方 gms.shinfdn.org オープニング まずは会社紹介や事業紹介により、ZOZOのことを知ってもらう時間を設けました。ZOZOTOWNやWEAR by ZOZO(以下、WEAR)のサービス、計測事業などについて解説することで、この後のサービス体験&技術紹介の内容をより深く理解してもらうことを目指しました。 サービス体験&技術紹介 2つのグループにわかれ、「サービス体験&技術紹介」と「オフィスツアー」を交代で実施しました。「サービス体験&技術紹介」では、まずWEARのファッションジャンル診断を体験していただき、診断結果のステッカーをプレゼントしました。 「ZOZOGLASS」の体験では、肌の色を高精度に計測し、ZOZOCOSMEで販売しているアイテムの中から計測した肌の色に一番近いベースメイクを見つけてもらいました。肌の色を測る仕組みには理科の知識が生きていること、開発をニュージーランドのチームと英語でやり取りしながら進めていることも紹介しました。 今回は新しく、研究段階にある「触り心地が変わる布」も体験してもらいました。 「触り心地が変わる布」は、電気を流すことで同じ布の触り心地が「つるつる」から「ざらざら」へと変化するプロトタイプです。参加者の皆さんは、電気のオン・オフで変わる感覚に驚いた様子でした。この技術によって、インターネットで服を買うときに分からない「手ざわり」を画面の向こうに伝えられる日が将来来るかもしれません。 3つのサービス体験と技術紹介をとおして、技術によってファッションがより楽しく便利になることを感じてもらいました。 オフィスツアー こだわりの社屋である、西千葉本社のオフィスツアーを実施しました。メッセージが込められたアートや遊び心のある会議室、絨毯の模様や色使いの工夫など、ZOZOらしいデザインが施されたオフィス内を案内しました。クイズを交えながらの紹介で、参加者の皆さんも考えながら楽しんでいました。 今回は、会議棟「ZOZOTENT(ゾゾテント)」もこれまで以上に多くのエリアを回り、最新のオフィス環境をじっくり体験してもらいました。普段はなかなか見られない社内の様子に、参加者の皆さんも興味津々の様子でした。 パネルトーク 次にパネルトークを開催し、新卒1〜2年目の若手女性エンジニアから話を聞きました。学生時代の経験やエンジニアになろうと思ったきっかけ、中学・高校時代の進路選択などについて語ってもらいました。 昔から好きなことを貫いてエンジニアになった人もいれば、転学科を経てたどり着いた人もいるなど、進路の選び方は一人ひとり異なっているということが伝わる内容でした。決まった正解があるわけではなく、自分に合った道を見つけることが大切だと感じてもらえたのではないでしょうか。 質問会 その後は少人数のグループに分かれて参加者からの質問に答える時間を設けました。Web上で質問が投稿できるツールを活用して質問を集めたところ、たくさんの質問が寄せられ、エンジニア社員が自身の経験を交えながら丁寧に答えました。 お土産 参加者の皆さんに、ZOZOオリジナルグッズなどをお土産としてお渡ししました。イベントの思い出として楽しんでもらえたら嬉しいです。今回の体験時間に入りきらなかったZOZOMATもお渡ししており、自宅で足の3Dサイズ計測を体験してもらえればと思います。 最後に 参加者の皆さんからは、次のような感想をいただきました。 どんな仕事があるか上手く想像出来なくて、こんな仕事もあるんだなと知ることが出来ました 布の体験が楽しかったです!! 自分は服が好きなので服関係の仕事の様子が分かって良かったです。 ひとりひとりの社員さんが丁寧に質問に答えてくださってより自分の未来が想像しやすくなるようなイベントでした 勉強の意義が見出だせて良かった ZOZOはこれまでもさまざまな女性活躍推進のための活動に取り組んできており、今後もこうした機会を提供していきたいと考えています。本イベントにより中高生女子の皆さんがファッションと技術の面白さを感じ、将来の可能性を広げるきっかけになれば幸いです。
はじめに こんにちは、検索基盤部 検索グロースブロックの 朝原 です。普段はZOZOTOWNの検索体験の改善を担当しています。2026年6月29日から7月2日までサンフランシスコで開催されたAI Engineer World's Fair 2026に現地参加しました。本記事では、現地の様子に加えて、特に気になったセッションをSoftware FactoriesとAgentic Commerceの2つのテーマに分けて紹介します。 目次 はじめに 目次 AI Engineer World's Fair 2026とは 現地の様子 ノベルティ Expoエリア サイドイベント セッションレポート Software Factories Better Loops & Orchestrating Agents 所感 Self-Improving Software Factories 所感 Don't Ship Skills Without Evals 所感 Agentic Commerce Designing Multimodal Collaborative Agents for Next-Gen Commerce 所感 When AI Agents Pay and Sellers Monetize: Building x402 Apps for Agentic Commerce on AWS 所感 まとめ AI Engineer World's Fair 2026とは AI Engineer World's Fairは、サンフランシスコで開催されるAIエンジニア向けカンファレンスです。公式サイトによると今年は4日間の開催で、6000人以上の参加者と300人のスピーカーが集まりました。 www.ai.engineer 開催テーマにはSoftware Factories、Autoresearch、Harness Engineeringが掲げられていました。私が聴講した発表では、エージェントを単発の便利ツールとして使うのではなく、継続的に動かす仕組みを扱うものが目立ちました。 現地の様子 サンフランシスコの朝はたいてい曇っていましたが、時間が経つにつれて晴れてくる日が多く、過ごしやすい気候でした。 会場のMoscone Westの入口には、「Engineering the future of AI」というスローガンが大きく掲げられていました。 ノベルティ 受付では、ネームプレートやトートバッグなどのノベルティが配布されました。ネームプレートには名前と所属、職種が記載されていたため、会場内でも似たバックグラウンドの方に話しかけやすかったです。 トートバッグには、サンフランシスコの街並みと「AE」の文字を組み合わせたピクセルアートがあしらわれていました。 ユニークだったのが「AGI PILLS」と書かれたサプリメント風のノベルティです。飲めば知能がアップグレードされるという設定のジョークグッズで、AIカンファレンスらしい遊び心を感じました。 Expoエリア 会場のExpoエリアでは、各社がブースを構えて自社のサービスやプロダクトを紹介していました。実際にデモを試しながら説明を聞けるため、各社がエージェント開発のどこに課題意識を持っているのかを知ることができました。MicrosoftやOpenAIのブースには常に人だかりができていました。 ユニークな展示も多く、Bright Data社のブースでは来場者がロボットを操縦して対戦する企画が人気を集めていました。 サイドイベント カンファレンスの前後や夜には、協賛企業や団体が主催するサイドイベントも数多く開催されていました。私もいくつかのイベントに参加し、世界中から集まったAIエンジニアたちと直接話すことができました。 美術館を会場にしたサイドイベントでは、アート作品に囲まれながらの交流という貴重な体験ができました。 Cloudflareがカフェを貸し切って開催したカジュアルなイベントもあり、コーヒー片手にゆったりと話せる雰囲気でした。 セッションレポート 会場では、キーノート、テーマ別の講演、ワークショップが複数のトラックで並行開催されていました。 ここからは、5つのセッションをテーマごとに紹介します。 Software Factories Software Factoriesは今年の開催テーマのひとつで、専用のトラックも用意されていました。各発表において、Software Factoryは、「AIが開発工程を継続的に実行し、人間は要所で判断しながらAIを動かす仕組みを設計・改善する開発モデル」として説明されていました。 この工場が担うのはコードを書く工程だけではなく、ユーザーフィードバックやログといったシグナルの収集から、優先順位付け、実装、検証まで、ソフトウェア開発のライフサイクル全体を自律的に回すことです。 ここでは、人間とエージェントの役割分担を見直すオーケストレーション、開発フロー全体の自動化の実践、エージェントに渡すスキルの品質管理という順で3つのセッションを紹介します。 Better Loops & Orchestrating Agents OpenClawの開発者で、2026年にOpenAIへ入社したPeter Steinbergerさんの発表です。 Steinbergerさんは、現在のコーディングエージェントの運用では、人間がタスクの采配と確認を担うことが多いと指摘していました。人間がタスクを切り出してエージェントに依頼し、出力を待ち、レビューして修正を頼みます。このようにタスクを采配する役割はOrchestratorと呼ばれ、人間がOrchestratorである限り、エージェントの数をいくら増やしても人間の確認速度が全体の上限になります。これまではモデルの性能の問題もあり、エージェントが意図しない行動をしたら人間が止めて指示し直す、という使い方をしてきました。しかしモデルが優秀になってきた今、「エージェントがコードを生成する画面をただ眺めているのはもったいない」とSteinbergerさんは指摘していました。 人間がOrchestratorを担う、これまでの構成 / Better Loops & Orchestrating Agents — Peter Steinberger, OpenAI 49:06より引用 そこで提案されたのが、Orchestratorを人間からエージェントに引き渡す構成です。Managerエージェントが「プロジェクトの目標」「メモ」「将来のビジョン」をもとにタスクを切り出し、Workerエージェントを作ります。Workerが調査・実装・テストを進め、Reviewerエージェントが結果を確認します。タスクの切り出しから実装・レビューまでの日常のループは、人間が介在しなくても回り続けます。 Managerエージェントがタスクを采配する、提案された構成 / Better Loops & Orchestrating Agents — Peter Steinberger, OpenAI 49:13より引用 一方で人間が不要になるわけではなく、ループの外側で意思決定を受け持ちます。ツールの利用許可のように人間の判断が必要な場面では、Managerが確認を求めるようにします。 もうひとつの提言が、エージェントをターミナルに閉じ込めないことです。Slackなど、ターミナル以外の場所から指示や確認ができれば、人間は席にいなくてもループの外から関与できます。こうした、エージェントを動かし続けるための実行環境や仕組みはハーネス(Harness)と呼ばれ、冒頭で触れた開催テーマのひとつでもあります。ハーネスを作ることが、これからのエンジニアの仕事になると述べていました。 締めくくりの「The future isn't twenty terminals. It's better loops.」という言葉が、強く心に残っています。 所感 この発表を聞くまでの私は、まさにSteinbergerさんがアンチパターンと呼ぶ「人間がOrchestratorである」状態で、タスクの切り出しも個々のエージェントの起動も自分で行っていました。発表を聞いてからは、タスクを切り出す工程からエージェントに任せる構成を実践しています。具体的には、社内の情報源をエージェントに参照させ、そこからタスク候補を抽出してIssue化し、優先度を付けて改善を進めるループです。並列実行するエージェントも自分で個々に起動するのではなく、Managerエージェントに任せているので、人間は指示と承認というループの外側の関与に集中できています。 ループが回っている間、人間は要件定義など別の仕事を進められます。実際にこの構成へ変えてから、私の1週間あたりのPR作成・マージ数はそれまでの約4倍になりました。ただしこれは担当タスクの性質や時期の違いを取り除いた厳密な計測ではないため、あくまで手応えを表す目安として捉えています。 一方で、人間がループの外側に出るほど、今度は承認の質そのものが問われるとも感じています。現状はManagerエージェントが判断を求めてきた場面でしか関与していないため、エージェントが問題に気づかないまま進んだケースを検知する手立ては持てていません。ループを回す速さと同じくらい、どこで人間が見るべきかの設計が重要になりそうです。 Self-Improving Software Factories Software Factoryは多くのスピーカーが語ったテーマですが、ここでは評価指標まで示したWarp創業者Zach Lloydさんの発表を紹介します。 主張を一言でいうと「Software EngineeringはFactory Engineeringになる」です。 エンジニアの仕事は、コードを書くことから、工場のオートメーションのように環境を整えて工場全体を制御することへ変わる、という意味です。 背景には、2026年4月に 自社のターミナル製品Warpをオープンソース化 したWarp自身の経験があります。発表では、これを契機に開発速度とコストが改善したと説明されていました。一方で、IssueやPRが乱立する課題も生まれました。個々の実装をエージェントに任せるだけでは、手前のタスク管理と後工程の検証が人手に残り、増え続けるIssueやPRを処理しきれないためです。 そこでWarpは、入力から運用までを次のような一本のAgenticフローとしてつなぎました。 SlackやWiki、Issueなどの情報源から問題を収集する 問題の大きさや複雑さといった観点からタスクへ落とし込む タスクの解決策や方針を立てる コーディングエージェントが実装する エージェントがレビューする(人間のレビューは任意) その変更がユーザーにどう影響するかを、エージェントがComputer Useで検証する CI/CDでリリースする リリース後のモニタリングでエラーを検知すると、エージェントが原因を突き止めて1の情報源に追加する 8で見つかった問題が再び1の入力になるため、不具合の修正までがループの中に入っています。 Software Factoryのループ図 / Self-Improving Software Factories — Zach Lloyd, Warp 4:55:21〜より引用 また、本発表ではFactory Engineeringの品質を評価する概念的な指標として以下が示されました。 Factory efficiency = Software shipped / (token + human cost) 出荷できたソフトウェアの量をトークンコストと人件費の合計で割った値であり、人の作業とトークン消費の双方をコストとして評価する考え方を示しています。 さらに、Issueを処理する日々のループ(Inner loop)とは別に、ループ自体を改善するループ(Outer loop)を回すべきだと述べていました。これによってループを人間が直接改善せず、ループ自体の改善も自動で回すことができます。 Inner loopとOuter loopの図 / Self-Improving Software Factories — Zach Lloyd, Warp 4:55:21〜より引用 所感 前のセッションで紹介したManager構成は、この発表でいうInner loopにあたります。現状の私は、マージ済みPR数というアウトプット量だけを見ており、出荷量や品質、人の工数、トークン費用まで含めた評価はできていません。Factory efficiencyの発想を使えばこうしたコストまで含めてループの良し悪しを評価できるため、まずは自分のループをこの指標で測り、Outer loopも回していきたいです。もっとも、分子である出荷できたソフトウェアの量をPR数で測るのか機能単位で測るのかは自明ではないため、実際に運用するならこの定義を決めるところから始める必要がありそうです。 Don't Ship Skills Without Evals Google DeepMindのPhilipp Schmidさんによる、エージェントに渡すスキルの作り方と品質管理を扱った発表です。 スキルとは、スクリプトやテンプレート、参考資料をSKILL.mdファイルを中心にひとつのフォルダにまとめたもので、特定の業務手順やベストプラクティスをエージェントに教える役割を持ちます。スキル自体は、エージェントに「今の流れを再現するスキルを作って」と頼むだけでも作れますが、作ったスキルがそのまま良いものとは限りません。この発表では、良いスキルを作るためのベストプラクティスが紹介されていました。 What Is a Skill? / Don't Ship Skills Without Evals — Philipp Schmid, Google DeepMind 2:28より引用 紹介された指針は、スキルがコンテキストを圧迫しないようにする、という観点で一貫していました。 まず、スキルの説明文(メタ情報)は最小限にします。多くの実装では、説明文がエージェントの全ターンでコンテキストに入るため、長い説明文はコストがかさむうえ、LLMの性能低下にもつながると説明されていました。 あわせて、説明には「こういう時には使用しない」など明確なユースケースを記載します。たとえばReactのデザインスキルに「Reactで使う」とだけ書くと、デザインと無関係なリファクタリング中にもスキル全体が読み込まれてしまいます。 本文の書き方では、手順を細かく指定せず、目標と制約を書くことが推奨されていました。手順を固定すると、エージェントがエラーから回復したり、より良い方法を選んだりする余地を奪うためです。ステップ1、ステップ2とフロー化したい処理なら、スキルではなくスクリプトにして実行させるべきと指摘されていました。 また、「高品質なコードを書いて」のように挙動へ影響しない記述はNo-Opsと呼ばれ、今のLLMにはこうした飾りの指示は不要でトークンを消費するだけのため、削除すべきだと説明されていました。 そして、モデルは賢くなり続け、スキルがなくてもできることは増えていくため、スキルは作って終わりではなく定期的に見直します。スキルあり・なしで評価を回すablationテストを行い、差がなくなったスキルは引退させることが推奨されていました。 所感 スキルがコンテキストを圧迫する問題は以前から耳にしていましたが、実際にどう減らすかという取り組みまではできていなかったところに、そのまま実践に移せる具体的な指針を得られました。 一方で、これらのベストプラクティスを人間が覚えてスキルを手で書き続けるのは難しいとも感じるため、エージェントにベストプラクティス自体を渡し、スキルの作成や整理を任せる仕組みを整備していきたいです。 Agentic Commerce Agentic Commerceは、ECサイトの開発に携わる立場から個人的に注目していた領域です。エージェントと一緒に買い物する体験の設計と、エージェントが支払いをするための決済インフラの順で、2つのセッションを紹介します。 Designing Multimodal Collaborative Agents for Next-Gen Commerce Google DeepMindのNidhi Vyasさんによる、ユーザーがエージェントとともに欲しいものを見つけていく体験をどう設計するかという発表です。 発表の前提にあるのは、すべてのユーザーが自分の欲しいものを明確に言語化できるわけではなく、曖昧なゴールしか持たないユーザーが多いという点です。そうしたユーザーには、ビジュアル情報を確認しながらインタラクティブに買い物を進められる体験が合う、というのがVyasさんの主張です。発表では、エージェントと一緒にショッピングを進めるこの体験をCo-Shopping Loopとして定義していました。 The Co-Shopping Loop / Designing Multimodal Collaborative Agents for Next-Gen Commerce(Nidhi Vyas, Google DeepMind)のスライドより引用 ループは以下の3ステップで構成されます。 Discovery:商品を提案するステップ 長期記憶として保持した対話履歴、時間や位置情報、予算やスタイルといった制約をもとに最適な候補を選ぶ 制約は、予算の上限のようなハードな制約と、スタイルの好みのようなソフトな制約に分けて扱う Multimodal Elicitation:ユーザーの意図をくみ取るステップ 発話だけでなく、クリックやマウスのホバー、スクロールといった操作を取得してエージェントにフィードバックする あるスタイルの提案に15秒ホバーした、といった行動を、言葉ではない潜在的な好みとして利用する Adaptive Response:応答の仕方を最適化するステップ ここまでに集めたパーソナライズ情報からユーザーの求める答えの形を予測し、探索段階に応じて構造化テキスト・ビジュアルギャラリー・比較表を使い分ける じっくり比較したいのか、多様な商品をまず眺めたいのか、購入を後押しする説明がほしいのか、ユーザーの潜在的な意図はさまざまです。比較ボタンや一覧表示の切り替え動線を用意しても、すべてのユーザーが能動的に探索するとは限りません。そのため、内容だけでなく見せ方まで、その時点のユーザーの状態に合わせて選ぶべきだとVyasさんは述べていました。 Adaptive Response / Designing Multimodal Collaborative Agents for Next-Gen Commerce(Nidhi Vyas, Google DeepMind)のスライドより引用 所感 エージェントと一緒に買いたいものを明確化していく体験は、検索体験を作る立場からも重要だと感じました。特に「比較したい人には比較表、多様な商品を眺めたい人には商品リスト」のようにユーザーの状態に応じて見せ方を出し分ける発想は、体験の質を大きく左右すると思います。 私自身、2つの商品で迷っているときもあれば、多くの商品からまずあたりをつけたいときもあります。ただ、その状態を言葉にしてエージェントへ伝えるのはユーザーにとって負荷の高い作業であるため、その部分をエージェント側が行動から推論して補うアプローチには大きな可能性を感じました。 When AI Agents Pay and Sellers Monetize: Building x402 Apps for Agentic Commerce on AWS AWSのAnil Nadimintiさんによる、エージェントの支払いを支える決済インフラをどう作るかという発表です。 前提となるのが、AIによる自動アクセスの急増です。発表では、botが全Webトラフィックの51%を占め、初めて人間を上回ったという調査結果が紹介されました。また、一部の企業ではWebトラフィックの95%がAIスクレイピングbot由来だと引用されていました。 AIエージェントによるトラフィックの現状 / When AI Agents Pay and Sellers Monetize: Building x402 Apps for Agentic Commerce on AWS(Anil Nadimineti, AWS)のスライドより引用 発表では、エージェントからのアクセスに対するサイト側の対応が、ブロックか受け入れかの二択として整理されていました。 ブロックすれば基盤への負荷は抑えられるが、AI経由でユーザーに見つけてもらう機会を失う 受け入れれば露出の機会は増えるものの、基盤を強化する必要がありコストも高くつく 実際にはレート制限や認証といった中間的な対策もありますが、それだけではコンテンツ利用に応じた対価を得られません。個別のライセンス契約で対価を得る方法もあるものの、少額・高頻度のアクセスには適用しにくい課題があります。 botをブロックする場合と受け入れる場合のトレードオフ / When AI Agents Pay and Sellers Monetize: Building x402 Apps for Agentic Commerce on AWS(Anil Nadimineti, AWS)のスライドより引用 発表では、支払い要求に応じるエージェントに対してアクセス単位で課金する、という第三の選択肢が提示されました。ブロックか受け入れかの二択に、対価を得ながら受け入れる道を加える発想です。ただし、従来の課金モデルはエージェントに合わず、たとえば「月100万円で1万リクエストまで」のようなプランは、少ししか使わない利用者にはハードルが高すぎます。かといって1リクエストごとにクレジットカードで請求すると、代金よりカード手数料の方が高くなることもあります。エージェントのアクセスは少額・高頻度のため、それに合った決済の仕組みが必要です。 そこで登場するのが、Coinbase社が開発したエージェントのための決済プロトコルx402です。2026年4月にLinux Foundationへの移管の意向が発表され、同年7月14日には移管の完了とx402 Foundationの運営開始が発表されました。名前の由来であるHTTPステータスコード402(Payment Required)は、支払いが必要なことを表すコードとしてもともと仕様に存在していたものの、ほとんど使われてきませんでした。x402はこの402を、エージェントへの支払いリクエストとして利用します。 x402による支払いの流れは次のとおりです。 エージェントが有料URLへアクセスする サイト側が金額・支払い先・利用できる決済方法といった支払い要件を含む402レスポンスを返す エージェントがウォレットで支払いに署名し、その支払い証明をリクエストヘッダーに付けて再度リクエストする サイト側が支払い証明を検証・決済し、確認できたら有料情報を返す クレジットカードのフォーム入力を挟まず、HTTPのやりとりだけで支払いが完結します。 この流れはAWSにも広がっており、Amazon BedrockのAgentCore Paymentsがx402をサポートしています(2026年8月時点ではプレビュー提供です)。 x402 Key Milestones / When AI Agents Pay and Sellers Monetize: Building x402 Apps for Agentic Commerce on AWS(Anil Nadimineti, AWS)のスライドより引用 買う側は、Coinbase CDPやStripeのウォレットをAgentCoreに接続し、エージェントはセッションごとに設定した予算の範囲でx402を使って有料情報を取得します。売る側については、AIエージェントのアクセスを検知してカテゴライズし、エンドポイントごとに利用料を設定して売上をダッシュボードで確認できる構成が発表では紹介されていました。 また、別セッション「Why Your AI Agent Needs a Wallet」では、Circle社のHarshal Bhangaleさんが登壇していました。この発表では、x402の直近の取引量と、Agentic Commerceの将来予測に触れられていました。スライドによると、2026年6月30日時点の直近30日間でx402の取引量は2400万ドルを超えています。あわせて、2030年にはAgentic Commerce市場全体が5兆ドル規模になるという予測も紹介されていました。 x402の市場規模 / Why Your AI Agent Needs a Wallet(Harshal Bhangale, Circle)のスライドより引用 所感 AIエージェントにアクセスされることでユーザーへの露出が増える一方、その負荷はサイトの維持費に跳ね返ります。だからこそ、アクセスに対して対価を得るという発想は合理的だと感じました。 エージェント側にとっても、これまでアクセスできなかったデータを利用できるようになるメリットがあります。売る側と買う側の両方が変わることで、AIエージェントを軸にした市場自体が大きく動いていくと感じました。 まとめ 本記事ではAI Engineer World's Fair 2026の現地の様子と、気になったセッションを紹介しました。Software Factoriesの3セッションでは、人間が個々のタスクを采配する構成から、エージェントが実行ループを管理する構成への移行が提案されていました。私自身のManager構成による開発フローでは、品質やコストまで含めた評価方法が今後の課題です。Agentic Commerceの2セッションからは、ユーザーの状態に合わせて見せ方まで出し分ける体験設計と、エージェントのアクセスを収益化する決済インフラの動きを確認できました。ZOZOTOWNの検索体験にどう取り入れられるか、引き続き検証していきます。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは。商品基盤部の藤本です。 私たちのチームでは、生成AIコーディングエージェント(以下、AIエージェント。主にClaude Code)を使って開発に取り組んでいます。以前からAIエージェントに一貫した実装をしてもらうため、ルールを書いて指示する運用を続けてきました。しかし、ルール同士の矛盾やレビューだけでは遵守を保証できないといった課題がありました。本記事ではこの課題と、ArchUnitによる機械的な検証へ落とし込むまでの取り組みを紹介します。 目次 はじめに 目次 背景・課題 ルールの読者はAIであるという前提の転換 自然文のルールからArchUnitの実行可能な検証への変換 ルール文書・テスト・実コードの三者整合性を担保する方法 アドホックな知識からルールへの体系的な移行 複数リポジトリへのルール展開の検討と見送り 検証の必須ゲート化 まとめ 背景・課題 AIエージェントに一貫した実装をしてもらうには、コーディング規約やアーキテクチャの制約をルールとして書き、AIエージェントに読ませる必要があります。私たちのチームでも命名規約やレイヤー間の依存関係に関する制約を、Markdownのルール文書として整備してきました。 ただ、ルールの数が増えるにつれて2つの問題が現れました。 1つ目は、ルール同士の矛盾や重複です。新しいルールを追加するとき、既存のルールと似た内容が別の場所にも書かれていたり、条件が食い違っていたりする場面がありました。ルールの数が少ないうちは目視で気づけますが、数が増えるほど見落としが発生しやすくなります。 2つ目は、ルールの遵守を保証する手段が、レビューに依存してしまう点です。AIエージェントの生成したコードのルール準拠を、毎回目視で確認する運用ではレビュアーの負担は増え続けます。レビューで見逃した違反は、そのまま実装に残ってしまいます。 さらに、ルールの書き方自体にも見直すべき点がありました。人間向けの文書と同じ作法をAIエージェントにそのまま適用しても、意図した通りに機能しない場面がありました。 ルールの読者はAIであるという前提の転換 私たちは当初、人間向けのドキュメントを書くときと同じ作法でルール同士を相互参照させたり、背景の説明を書き添えたりしていました。人間がドキュメントを読むときは関連する複数のルールを渡り歩きながら理解を組み立てるため、相互参照がその手がかりです。 しかし、AIエージェントがルールを読む場面は、実装やレビューのたびに発生します。そのたびに複数のファイルを渡り歩いて文脈を組み立てる必要があると、参照が一段増えるごとに読み落としや誤読の起点が増えてしまいます。 そこで私たちは、「ルールの読者はAIである」という前提に立ち返りました。人間向けの相互参照は、複数の文書をまとめて理解する読み手を助けるための工夫です。AIエージェントに同じ役割を期待する必要はなく、それぞれのルールが単体で完結し、判断に必要な情報がその中でそろうように書き直すことにしました。単体で完結する形にしたことで同じ説明が複数のルール文書に重複することを懸念していました。実際に運用してみると重複は目立って増えておらず、人間のレビュアーがルール文書を読む頻度や体験にも変化はありません。 この転換は書き方を変えるだけでは不十分で、ルールが本当に守られているかを機械的に検証できる形に変換する必要がある、という次の課題につながりました。 自然文のルールからArchUnitの実行可能な検証への変換 前提を転換しただけでは、ルールが実際に守られているかどうかまでは保証できません。次に取り組んだのは、自然文で書いていたルールをArchUnitで実行可能な検証に変換することです。 ArchUnitは、Javaのアーキテクチャ制約をテストコードとして記述し、ビルド時に検証できるライブラリです。パッケージの依存関係やクラスの命名規則、メソッドの呼び出し制約などをDSLとして表現できます。 たとえば、「ドメイン層のコードではリフレクションを使用しない」という規約は、これまでルール文書に自然文で書き、実装時にレビュアーが目視で確認していました。この規約をArchUnitで検証しようとすると、まず思いつくのは、パッケージ単位で禁止する書き方です。 なお、本記事のコード例は、実際にはSpockで実装しているルールをJUnit 5形式に書き直したものです。 import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; @AnalyzeClasses (packages = "com.example.domain" ) class ArchitectureRuleTest { @ArchTest static final ArchRule domain_should_not_use_reflection = noClasses() .should() .dependOnClassesThat() .resideInAPackage( "java.lang.reflect.." ) .because( "リフレクションはドメイン層の意図を分かりにくくするため禁止する" ); } しかし、この書き方には問題がありました。 dependOnClassesThat().resideInAPackage(...) は、対象パッケージのクラスへの依存を広く検出します。メソッド呼び出しだけでなくフィールドや引数の型宣言も依存に含まれるため、リフレクションを直接呼び出していないコードまで違反として検出してしまいます。 私たちのプロジェクトでは、O/Rマッパーが生成するコードの一部が java.lang.reflect.Method 型のフィールドを持っていました。生成コードが呼んでいるのは、内部でリフレクションを使うライブラリのヘルパーメソッドです。生成コード自身がリフレクションAPIを直接呼び出しているわけではありません。しかし、パッケージ指定による型参照チェックでは、この生成コードも違反として検出されてしまいます。 そこで、検出対象を「型参照」ではなく「メソッド呼び出し」に絞った ArchCondition として実装しました。 import java.util.Set; import com.tngtech.archunit.core.domain.JavaCodeUnit; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchCondition; import com.tngtech.archunit.lang.ArchRule; import com.tngtech.archunit.lang.ConditionEvents; import com.tngtech.archunit.lang.SimpleConditionEvent; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.codeUnits; @AnalyzeClasses (packages = "com.example.domain" ) class ReflectionRuleTest { private static final Set<String> CLASS_LOOKUP_METHODS = Set.of( "forName" , "newInstance" , "getMethod" , "getDeclaredMethod" , "getMethods" , "getDeclaredMethods" , "getField" , "getDeclaredField" , "getFields" , "getDeclaredFields" , "getConstructor" , "getDeclaredConstructor" , "getConstructors" , "getDeclaredConstructors" ); @ArchTest static final ArchRule domain_should_not_call_reflection = codeUnits().should( new ArchCondition<JavaCodeUnit>( "not call reflection / dynamic-dispatch APIs" ) { @Override public void check(JavaCodeUnit codeUnit, ConditionEvents events) { codeUnit.getMethodCallsFromSelf().forEach(call -> { var target = call.getTarget(); var ownerName = target.getOwner().getFullName(); var ownerPackage = target.getOwner().getPackageName(); var name = target.getName(); boolean violation = (ownerName.equals( "java.lang.Class" ) && CLASS_LOOKUP_METHODS.contains(name)) || ownerPackage.equals( "java.lang.reflect" ) || ownerPackage.equals( "java.lang.invoke" ); if (violation) { events.add(SimpleConditionEvent.violated( codeUnit, codeUnit.getFullName() + " calls " + ownerName + "#" + name)); } }); } }); } このテストでは、コードが実際に呼び出したメソッドの呼び出し先(オーナーの型とメソッド名)を1件ずつ確認します。 java.lang.reflect パッケージや java.lang.invoke パッケージへの呼び出しは検出対象です。加えて、 java.lang.Class が持つ forName や getDeclaredMethod のような動的なメソッド探索・生成系のメソッド呼び出しも検出対象にしています。 java.lang.Class 自体は java.lang パッケージに属しており、 java.lang.reflect パッケージの外にあります。そのためパッケージ名だけでは判定できず、対象にしたいメソッド名を CLASS_LOOKUP_METHODS として自分たちで列挙しています。これはArchUnitが提供するAPIではなく、プロジェクト側で定義した定数です。型を参照しているだけのコードは対象にならないため、生成コードを誤検出することもありません。 検出範囲もメソッド本体だけでなく、コンストラクタやフィールドの初期化子まで含めた全コードユニット( codeUnits() )にしています。これによって、レビュアーが目視で確認していた範囲を、CIで機械的に検証できるようになりました。 命名規則やレイヤー間の依存関係も同様に、目視確認からArchUnitでの検証へ順次置き換えていきました。自然文のルールをすべて機械的に検証できるわけではありません。ただし、パッケージ構造やクラス間の関係など、構造的に表現できる制約は、この方法でカバーできます。 実際に機械的な検証へ変換したルールには、次のようなものがあります。 日時を扱うクラスの now() メソッドを、 Clock 引数を指定せずに直接呼び出すことを禁止するルール(テストで時刻を固定できるようにするための制約) 特定のインタフェースを実装したrecordのcompact constructorに Objects.requireNonNull 呼び出しを必須にするルール(nullチェックの実装漏れを防ぐ制約) UseCase層のクラスが持つ特定のメソッドに、 @Transactional アノテーションを必須にするルール(トランザクション境界の付け忘れを防ぐための制約) これらはいずれも、以前は自然文のルール文書とレビューでの目視確認に頼っていたものです。 ルール文書・テスト・実コードの三者整合性を担保する方法 ルールをArchUnitのテストに変換しても、それだけでは安心できません。ルール文書とテストコード、そして実コードの3つは、別々のファイルに書かれているため、時間が経つとずれていく可能性があります。 実際に私たちのルール文書にある例を紹介します。あるUseCaseクラスの設計規約では、公開メソッドに @Transactional を必須にし、 isolation 属性はデフォルトのまま変更しないことを原則としています。ただし、バッチ処理で定期的に更新されるデータを参照する1つのUseCaseだけ、例外として REPEATABLE_READ を指定してよいことになっています。 // OK: 分離レベルを上げる場合は理由をコメントで明示する(唯一の例外) @Transactional (isolation = Isolation.REPEATABLE_READ) // バッチで定期的に更新されるデータを参照するため、整合性を保つ public ResultDTO process(SomeCommand command) { /* ... */ } この例外は、ルール文書に書くだけでは終わりません。ArchUnitのテストコード側にも、この特定のクラスを検査対象から除外する条件を書く必要があります。 import com.tngtech.archunit.core.domain.JavaMethod; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchCondition; import com.tngtech.archunit.lang.ArchRule; import com.tngtech.archunit.lang.ConditionEvents; import com.tngtech.archunit.lang.SimpleConditionEvent; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; @AnalyzeClasses (packages = "com.example.usecase" ) class UseCaseTransactionalRuleTest { @ArchTest static final ArchRule isolation_should_be_default = methods() .that().areDeclaredInClassesThat().resideInAPackage( "com.example.usecase.." ) .and().areDeclaredInClassesThat().areAnnotatedWith(Service. class ) .and().areDeclaredInClassesThat().doNotHaveFullyQualifiedName(SampleUseCase. class .getName()) .and().areAnnotatedWith(Transactional. class ) .should( new ArchCondition<JavaMethod>( "have the default isolation level" ) { @Override public void check(JavaMethod method, ConditionEvents events) { var isolation = method.getAnnotationOfType(Transactional. class ).isolation(); if (isolation != Isolation.DEFAULT) { events.add(SimpleConditionEvent.violated( method, method.getFullName() + " has isolation " + isolation)); } } }); } ルール文書の例外規定と、テストコードの doNotHaveFullyQualifiedName(...) は、別々のファイルに書かれた対応関係です。この対応が崩れると、新しく追加した例外が検査対象のままになって意図せずテストが失敗したり、逆に例外ではないクラスが誤って除外されたままになったりします。私たちのルール文書には、「新たに例外が必要になった場合は、テストコードの除外条件も合わせて更新する」という注意書きを直接添えています。これによって、ルール文書とテストコードの対応関係を明示しています。ただし、この注意書き自体も、人が読んで実行することに変わりはありません。例外の宣言をコード側に持たせ、テストがそれを直接参照する形にすれば、注意書きなしでも対応関係を保てます。この点は今後改善する余地として残っています。 もう1つの工夫は、ルール文書自体に「検査の保証範囲」を明記することです。たとえば、先ほどのUseCaseの設計規約には、次のような記載があります。 機械的チェックの現状: process への @Transactional 必須とisolation制約は、ArchUnitのテストで既に機械的に検査されている。一方、メソッドの公開範囲や引数の形に関する規約はまだ自動検査されておらず、レビューでの目視確認に依存する。 この記載があることで、AIエージェントも人間のレビュアーもルール文書を読むだけで、どこまでが保証されていて、どこからが目視確認頼みかを判断できます。 こうした工夫に加えて、ルールを新しく追加したときには、AIエージェントに三者を突き合わせて確認してもらうこともあります。対象は、ルール文書とArchUnitのテストコード、実コードです。矛盾点や、ルール文書の説明と実装の食い違い、目視確認に頼っている部分の取りこぼしがないかを検証してもらう狙いです。 アドホックな知識からルールへの体系的な移行 ルールをArchUnitで検証する仕組みや、三者の整合性を保つ運用を整えても、その対象になるルール自体がどこにあるかが分かりにくいという問題が残っていました。 私たちのプロジェクトには、設計判断の理由や実装パターンをまとめた知識ベースがありました。この知識ベースには、「なぜこの設計を選んだか」という背景説明と、「必ず守るべき制約」が同じ文書に混在していました。背景説明は読み手の理解を助けるものであり、検証の対象にはなりません。一方、制約は本来、検証の対象になり得るものです。両者が同じ文書に混ざっていると、どの記述がArchUnitで検証すべき対象なのかが分かりません。 そこで、知識ベースと制約を別々のディレクトリに分けました。背景説明や実装パターンは .claude/knowledge/ に置き、必ず守るべき制約は .claude/rules/ へ配置しました。なお、 .claude/rules/ は現在Claude Codeが標準で読み込むディレクトリですが、 .claude/knowledge/ はこのプロジェクト独自の配置です。標準的な文書配置がまだ定まっていなかった時期に、リポジトリの直下には置きたくないという理由で .claude/ 配下に作ったものです。 たとえば、アーキテクチャに関する文書は、同じ architecture.md という名前で両方のディレクトリに存在します。 .claude/knowledge/architecture.md には、オニオンアーキテクチャを採用した理由や各層の実装パターンといった、背景の説明が書かれています。 .claude/rules/architecture.md には、Controller・UseCaseをファットにしないための具体的な閾値や、NG・OKのコード例が書かれています。これらは、コード生成時に従うべき強制基準だけに絞られています。後者の冒頭には、次のような記載があります。 各層の責務概要・実装パターン・選択理由は .claude/knowledge/architecture.md に記載してあり、本ファイルはその知識を前提とした上で「コード生成時に従う強制基準」を定義する。 この参照は、ルールを単体で完結させるという前提の転換と矛盾しているように見えます。しかし、コード生成時に守るべき強制基準は .claude/rules/architecture.md 側で完結しています。 .claude/knowledge/architecture.md を読まなくても遵守の判断はできます。knowledge側は、なぜその基準になったかという任意の背景情報であり、参照しなくても強制基準の適用に支障はありません。 .claude/knowledge/ は .claude/rules/ と異なり無条件では読み込まれず、CLAUDE.mdの案内に沿って必要な場面ごとに参照先が示される配置です。そのため、AIエージェントが両者を区別せずまとめて読み込んでしまう事態は今のところ起きていません。 この整理によって、「検証すべき制約の一覧」がルール文書側にまとまりました。新しいルールを追加するときも、書く場所を選ぶ時点で性質を判断するようになりました。単なる背景知識なのか、AIエージェントに守らせたいルールなのか、それとも機械的に検証できる制約なのか、という観点です。 複数リポジトリへのルール展開の検討と見送り 1つのリポジトリでルールとArchUnitによる検証の仕組みが定着したところで、次に考えたのは、他のリポジトリでも同じ仕組みを使えるようにすることでした。命名規約やアーキテクチャの制約には、プロジェクトが違っても通用する部分があります。1つのリポジトリで整備したルールを他のプロジェクトでもそのまま使えれば、同じルールをゼロから作り直す手間を省けます。 そこで、ルールをマーケットプレイス形式で管理し複数のリポジトリへ配布する仕組みと、配布したルールを管理するツールを用意する計画を立てました。ここでの「マーケットプレイス形式」は、既存のプラグイン配布基盤を指すものではなく、複数のリポジトリへルールを公開・取得できるようにする、自前の配布基盤のことを指しています。 しかし、計画を進める中で見えてきたのはツールの設計上の課題ではなく、運用面の課題でした。ルールを共有する仕組み自体は用意できます。ただし、それぞれのリポジトリを担当するメンバーが、他のリポジトリで整備されたルールを積極的に取り込むかどうかは別の問題です。実際には、他のリポジトリのルールを取り込む動きはほとんど生まれませんでした。 取り込みが進まなかった背景には、大きく2つの理由があると考えています。1つは、リポジトリをまたいで本当に共有できるルールが想定していたほど多くなかったことです。プロジェクト固有の事情に依存するルールが大半で、汎用的に使い回せる部分は一部に留まりました。もう1つは、ルールを取り込むこと自体が、前述したルール文書・テスト・実コードの整合性を確認する運用という新たな運用コストを増やしてしまうことです。既存のルールをそのまま使うのではなく、自分たちのリポジトリの実装に合わせて調整し、整合性を保ち続ける必要があり、その手間が取り込みのハードルになっていました。なお、この2つの理由は、実際の取り込み状況から私たちが振り返って推測したものです。 この結果を受けてマーケットプレイスと管理ツールの計画は取りやめました。仕組みを作ることが目的化してしまうと、実際には使われない仕組みを維持するコストだけが残ってしまいます。需要が確認できていない段階で大掛かりな仕組みを作るより、まずは個別のリポジトリでルールと機械的な検証の仕組みを定着させることを優先する判断です。 この判断を見直す条件があるとすれば、共有できるルールの数がたまたま増えることではなく、共通化に必要な材料がそろうことだと考えています。ルールを先に一般化してから展開するトップダウンの手順は、複数チームでの実例が積み重なっていない段階では成立しにくいというのが、今回の見送りから得た実感です。各チームがルールの背景を記録しつつ、記録した内容をチーム横断で比較する仕組みと運用が必要です。ルール文書・テスト・実コードの三者突き合わせと同様に、この比較もAIエージェントに任せられる見通しが立った時点で、改めて展開を検討したいと考えています。 検証の必須ゲート化 ArchUnitのテストを書いても、実行される保証がなければ意味がありません。テストコードとして存在していても、実行タイミングが曖昧だと気づかないうちに検証が素通りしてしまうことがあります。 私たちのプロジェクトには、SpockベースのArchUnitルールとは別の検証ルールもあります。たとえば、参照型を返すメソッドに @Nonnull ・ @Nullable のいずれかを必須にするルールなどがあります。このルールは check タスクに組み込まれていましたが、 test タスクには組み込まれていませんでした。これは意図的な設計ではありませんでした。Gradleの標準的なタスク依存構造( check が test に依存する一方、逆方向の依存はない)上、静的な検証系のタスクが慣習的に check 側へ接続されることによるものでした。この違いに気づかないまま ./gradlew test だけをローカルで実行してプッシュしたところ、 check 側のルール違反がローカルでは検出されず、CIで初めて検出される事態が起きました。テストが通ったことを確認しても、 check の検証は素通りしていたということです。 そこで、プッシュ前に実行すべきコマンドを ./gradlew check に統一しました。 check タスクはSpotlessによるフォーマットチェック・全テスト・ルールの検証をまとめて実行するため、 test だけを実行して安心してしまう事態を防げます。この方針はルール文書側にも明文化しています。 あわせて、CI側でもプルリクエストごとに検証しています。ただし、CIでは check タスクをそのまま呼ぶのではなく、構成要素を複数のジョブに分けて並列実行しています。DBを使うテストと使わないテストを別ジョブに分けてDBコンテナの要否を切り分け、フォーマットは「チェックして落とす」のではなく「自動修正してコミットする」別ジョブにしているためです。ルールを検証するジョブは、GitHubのブランチ保護ルールで必須ステータスチェックに指定されています。ルールの検証に失敗すると、そのプルリクエストはマージできません。ローカルでの実行規律だけでなく、CIでも強制することで、実行を忘れたまま気づかずマージしてしまう事態を防いでいます。 新しいルールを追加するときは、検証が実際に機能しているかどうかも確認します。たとえば、ドメイン層のパッケージ配置に関するルールを追加したときは、意図的にルールに違反する配置のダミークラスを用意し、検証が失敗(FAILED)することを確認しています。ルールを追加した時点で既存のコードに違反がないかどうかも確認し、違反がゼロであることを確かめてからマージしています。 こうして、ArchUnitなどを用いた検証は、書いただけのテストコードではなく、プッシュ前に必ず通過するゲートとして機能するようになりました。 まとめ 本記事では、AIエージェント向けにコーディングルールを書き、ArchUnitで機械的に検証する取り組みを紹介しました。 AIエージェントは、指示すればすぐに多くのコードを書いてくれます。しかし、その分だけルールに違反したコードが生まれる機会も増えます。レビューだけでこれを検出しようとすると、コードが増えるほどレビュアーの負担も増えていき、いずれ追いつかなくなります。ArchUnitでルールを実行可能な形にしておけば、検証の速度をコードが生成される速度に合わせられます。 この検証の仕組みを維持する作業にも同じ考え方が当てはまります。ルール文書とテストコード、実コードの整合性を確認する作業を人手だけで追いかけようとすると、ルールが増えるほど負担が増えていきます。そこで、この確認作業もAIエージェントに依頼しています。ルールを守る対象であるAIエージェントに、ルールを整備する側も手伝ってもらう、という体制です。 ルールの読者はAIであるという前提に立ち返ったことは、書き方を変えるだけの話ではありませんでした。ルールを「読んで理解してもらうもの」から「実行して確認できるもの」に位置づけを変える、という判断につながりました。AIエージェント向けにコーディングルールを整備することを検討している方がいれば、ぜひ参考にしてみてください。今後は、まだレビューでの目視確認に依存している規約も対象に含め、ArchUnitに限らずさまざまな手段で機械的に検証できる範囲を広げていきたいと考えています。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、検索基盤部の検索グロースブロックに所属する しゅがー です。普段はZOZOTOWNの検索体験を改善する施策の企画と開発を担当しています。 近年、CLIPをはじめとするマルチモーダルモデルの登場によって、画像とテキストを同じ特徴空間で比較できるようになりました。一方で、検索結果の並び順を学習するランキング学習(LTR: Learning to Rank)では、行動実績やテキストに基づく特徴量を使うことが一般的です。画像の情報をどう取り込むかには、まだ検討の余地があります。 本記事では、日本語CLIPモデルで算出した商品画像と検索クエリの類似度をランキング学習モデルの特徴量として追加すると精度がどう変わるのか、ZOZOTOWNの検索ログを用いてオフラインで検証します。類似度は、検索クエリをキーワードや性別、カラーといった属性に分解し、属性ごとに計算して複数渡します。検証の結果、nDCGは最大でおよそ0.7%改善し、渡す類似度の数を増やすほど改善幅は大きくなりました。要因として、各類似度が互いに異なる情報を持っていたことが示唆されました。なぜそう言えるのかSHAPを用いて掘り下げます。 目次 はじめに 目次 検証の背景と目的 CLIPを用いた画像・テキスト類似度の算出 CLIPとは 商品画像と検索クエリの類似度 オフライン検証の設計 検証結果 類似度とCTRの関係 類似度を特徴量として追加する 圧縮したベクトルを特徴量として追加する なぜ精度が改善したのか まとめ 検証の背景と目的 ファッションECの検索結果の画面をながめてみると、目に入る情報のほとんどは商品画像です。ブランド名や価格も表示されますが、ユーザーはまず画像を見て、クリックするかどうかを判断していると考えられます。 一方で、ランキング学習の特徴量としてよく使われるのは、クリック数や購入数といった行動実績や、価格やカテゴリーといった商品属性です。これらの特徴量は強力ですが、画像にしか現れない情報は捉えられません。たとえば「きれいめ ワンピース」のような検索では、その商品がきれいめかどうかは商品名や属性情報には必ずしも現れず、多くの場合、商品画像を見て判断するしかありません。 そこで本検証では、ランキング学習モデルの特徴量に商品画像と検索クエリの類似度を追加したとき、並び順の精度がどれだけ変わるのかをオフラインで調べます。 CLIPを用いた画像・テキスト類似度の算出 CLIPとは CLIP(Contrastive Language-Image Pre-training)は、画像とテキストを共通の特徴空間に埋め込むための訓練手法です。OpenAIが論文「 Learning Transferable Visual Models From Natural Language Supervision 」で提案しました。画像を入力するImage Encoderと、テキストを入力するText Encoderの2つを用意し、対応する画像とテキストのペアが近くに配置されるように学習します。 学習の流れは次のとおりです。 画像とテキストのペアをそれぞれのエンコーダーに入力し、特徴ベクトルを得る 画像側とテキスト側の特徴ベクトルの間でコサイン類似度を計算し、類似度の行列を作る 対応するペアの類似度が高く、対応しないペアの類似度が低くなるように最適化する CLIPの対照学習。対応するペア(対角成分)の類似度を最大化する この「画像とテキストを同じ空間で比較できる」という性質を使うと、商品画像と検索クエリがどれくらい似ているかを数値にできます。 商品画像と検索クエリの類似度 ZOZOTOWNの検索ログでは、ユーザーの入力した検索クエリが、キーワードと検索条件(性別やカテゴリーなど)に分解された形で記録されています。たとえば「メンズ シャツ 大きめ」という入力であれば、性別にメンズ、カテゴリーにトップス、サブカテゴリーにシャツ/ブラウスが対応します。 本検証では、この分解された次の7つの属性を対象にしました。 属性 内容 キーワード 検索条件に分解された後に残った検索キーワード 性別 メンズ、レディース、キッズの指定 カテゴリー トップスなどの大カテゴリー サブカテゴリー Tシャツ/カットソーなどの小カテゴリー カラー 選択された色 ブランド 選択されたブランド こだわり条件 ボーダー柄などの条件 キーワード以外は、ユーザーが明示的に指定していなくても値が埋まっていることが多い項目です。 これらの属性を、たとえば性別なら「男性の商品」、カラーなら「ホワイト系の商品」のように自然文へ変換したうえでText Encoderに入力し、商品画像との類似度を属性ごとに計算します。 手法の概要。図中の類似度の数値はダミーです。 利用したモデルは、LINEヤフーが公開している clip-japanese-base-v2 です。選定にあたっては次の2点を重視しました。 日本語で訓練、またはファインチューニングされていること 汎用的な多言語モデルと比べても、日本語の性能と汎化性能が同程度であること この2点を満たす候補モデルを複数選び、小規模なデータで商品画像と検索クエリの類似度を計算したうえで、類似度とCTRの相関がもっとも強かったものを採用しました。 オフライン検証の設計 ベースラインには、商品やユーザーの実績値に基づく特徴量で構成した勾配ブースティング木のランキング学習モデルを、本検証用に用意しました。このベースラインに画像由来の特徴量を追加し、精度の変化を比較します。 検証は2つの段階に分けました。 小規模なデータセットで、類似度とCTRの間に関係があるかを確認する ZOZOTOWNの検索ログを使って特徴量を追加したモデルを訓練し、精度の変化を評価する 1つ目の段階を挟んだのは、そもそも類似度がクリックの傾向と結びついていなければ、特徴量として追加しても意味がないためです。数万件規模のサンプルで7つの属性それぞれの類似度を計算し、CTRとの関係を調べました。 2つ目の段階では、追加する特徴量の入れ方を2つの方針で比較しました。 方針1:属性ごとの類似度を特徴量として追加する。複数の観点から画像の情報を渡すことを狙う 方針2:類似度に加えて、主成分分析で次元を削減した画像とテキストのベクトルを直接渡す。類似度では表せない細かい特徴も渡すことを狙う 評価指標にはnDCG@kを用い、ベースラインからの改善幅を比較します。訓練、検証、テストの各データは期間で分割しています。テストに使うのは、訓練よりも後の期間のログです。 検証結果 類似度とCTRの関係 はじめに、小規模なデータセットで類似度とCTRの関係を確認しました。 属性ごとに類似度の分布を見ると、キーワードと性別はほとんどの検索で類似度を計算できる一方、カラーやこだわり条件は指定される検索が少なく、類似度が欠損になりがちです。仮にCTRとの関係が強くても、欠損が多い属性は予測に貢献しにくいと考えられます。 次の図は、キーワードの類似度とCTRの関係です。類似度をビンに区切り、ビンごとのCTRを折れ線で、サンプル数を背景の棒で示しています。類似度が高いほどCTRも高くなる、右肩上がりの傾向が見て取れます。 キーワードの類似度とCTRの関係。軸の数値は非公開のため省略しています CTRとの相関を調べたところ、キーワードとカラーの類似度には有意な正の相関が見られました。画像と検索クエリが似ている商品ほどクリックされやすい、という関係を確認できたことになります。一方でカテゴリーやサブカテゴリーの類似度は、CTRとの関係がほとんど見られませんでした。 類似度を特徴量として追加する 次に、属性ごとの類似度をランキング学習モデルの特徴量として追加しました。 結果として、いずれの条件でもベースラインを上回るnDCGを記録しました。この傾向はnDCG@1とnDCG@10のどちらの評価位置でも同じです。さらに、追加する類似度の数を増やすほど改善幅も大きくなり、7つの属性すべてを追加したときにもっとも大きな改善が得られています。改善幅は最大でおよそ0.7%でした。 予測への貢献度を見ると、追加した類似度はいずれもベースラインの特徴量と比べて上位に入っていました。なかでもキーワードと画像の類似度の貢献度は高く、モデルがこの特徴量を予測に強く役立てていることが分かります。 また、単体ではCTRとの相関が弱かったカテゴリーやサブカテゴリーの類似度も、追加すると精度の向上に寄与しました。これは、勾配ブースティング木が単変量の相関では捉えられない非線形な関係を利用できるためだと考えられます。 圧縮したベクトルを特徴量として追加する 類似度はベクトル同士の関係を1つの数値に集約したものなので、その過程で捨てられている情報もあります。そこで、CLIPが出力したベクトルそのものを主成分分析で圧縮し、特徴量として渡す方法も試しました。 こちらも類似度だけを追加した場合より精度が改善しました。画像とテキストの両方のベクトルを渡したときの結果が、もっとも良好でした。渡す画像の情報を増やすほど精度が上がる傾向は、類似度の実験と同じです。 補足として、主成分分析の累積寄与率(圧縮後の次元で元のベクトルの分散をどれだけ説明できるかの割合)は、一般的な画像特徴量を圧縮する場合に比べてかなり低い水準にとどまりました。CLIPのベクトル空間では意味の情報が特定の次元に偏らず、多くの次元に分散して表現されているためだと考えられます。 なぜ精度が改善したのか 複数の類似度を追加するほど精度が上がる理由として、次の3つの仮説を立てました。 仮説A:画像に由来する特徴量の数が増え、予測に占める割合が高まったから 仮説B:各類似度が異なる意味を捉えており、情報の多様性が増したから 仮説C:複数の類似度が組み合わさり、決定木の分岐のパターンが豊かになったから 検証にはSHAPを用いました。SHAPは、モデルの個々の予測を特徴量ごとの寄与に分解する手法です。ある特徴量が予測をどれだけ押し上げ、または押し下げたのかを、サンプル単位で数値にできます。さらに、2つの特徴量が組み合わさったときにだけ生まれる寄与を交互作用として分けて算出できるため、仮説Cのような、特徴量の組み合わせに関するものも検証できます。 まず仮説Aについては、画像由来の特徴量が予測全体に占める寄与の割合が、類似度の追加によってどれだけ変わるかを調べました。結果として、類似度を7つに増やしても割合はわずかしか増えず、大半はベースラインの特徴量が占めたままでした。キーワードの類似度の使われ方も、単体で追加したときとほとんど変わりません。画像由来の特徴量が予測を支配するようになったから精度が上がった、という説明は成り立たないため、仮説Aは棄却しました。 次に仮説Cについては、類似度同士の交互作用による寄与の大きさを、それぞれの類似度が単独で持つ寄与と比べました。交互作用は主効果と比べて小さく、それぞれの類似度は独立に予測へ寄与していました。組み合わせの妙で効いているわけではないため、仮説Cも棄却しました。 最後に仮説Bについては、類似度同士がどれだけ重複した情報を持つかを確かめるため、類似度同士の寄与の相関を調べました。もし2つの類似度が同じ情報を捉えているなら、予測への効き方も似るはずで、寄与の相関は高くなります。実際に調べると、値はどれも非常に小さくなりました。唯一やや高い相関を示したのはカテゴリーとサブカテゴリーの組み合わせで、これは両者が親子の関係にあることを考えれば自然な結果です。この寄与の相関の低さは、追加する類似度の数を増やすほど精度が改善したという結果とも一致します。以上から、各類似度は互いに重複の少ない情報をモデルに渡せていたと考えられ、仮説Bを支持する結果が得られました。 この結果が示しているのは、属性ごとに分けて類似度を計算したこと自体が効いていた、ということです。類似度を1つだけ渡すよりも、「何と似ているのか」を属性ごとに分解して複数渡すほうが効く、という示唆が得られました。この考え方は、CLIPに限らず他の特徴量の設計にも応用できそうです。 まとめ 本記事では、ランキング学習モデルに商品画像の情報を追加するオフライン検証を紹介しました。 商品画像と検索クエリの類似度は、CTRと有意な正の相関を持つ 類似度をランキング学習モデルの特徴量として追加すると、オフライン評価でnDCGが最大でおよそ0.7%改善する 類似度だけでなく、圧縮した画像のベクトルを渡すとさらに精度が上がる 精度の改善に効いていたのは特徴量の数ではなく、互いに独立した情報を渡せていたこと ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、WEAR開発部Webブロックの吉田です。普段は WEAR の新機能開発や運用改善を担当しています。 Webブロックでは、異動によりメンバーが減ったことで、開発ワークフローのさまざまな工程で効率化が求められていました。本記事では、AIエージェントとGitHub Actionsによるワークフローの改善を取り入れ、アイデア出しやプルリクエストレビュー、エラー監視、KPTを改善した取り組みを紹介します。 目次 はじめに 目次 背景・課題 アイデア出しの属人化 プルリクエストのレビュー依頼が放置され、開発のリードタイムが伸びていた レビュー負荷が高く、観点の抜け漏れや待ち時間が発生していた Sentry・Datadog・Splunkのエラー確認が朝会で見切れていなかった KPTのProblemを掘り下げきれず、具体的なTryを出せていなかった 課題解決に向けたアプローチ Confluenceに過去のアイデアを蓄積し、AIによるアイデア出しを行う レビュー放置をGitHub ActionsとSlack通知で防ぐ 必要なApprove数の動的判定とRenovateの自動マージでレビュー負荷を下げる ツールごとのAIレポートでエラー確認のコストを下げる AIでMiroのKPTボードの議論を掘り下げる 取り組みの成果 アイデア創出の負担を抑えられるようになった プルリクエストの着手までのリードタイムが縮まった 朝会とKPTで議論から始められるようになった 今後の展望 まとめ おわりに 背景・課題 アイデア出しの属人化 Webチームには、SEOを含む成長施策の企画を専任するメンバーがいませんでした。そのため、送客率やアプリダウンロード数を改善するアイデアの創出は、担当者個人の経験に依存していました。また、日々の業務が忙しく、アイデア出しの時間を確保できていませんでした。 プルリクエストのレビュー依頼が放置され、開発のリードタイムが伸びていた Webブロックはメンバーが減ったことで、1人でもレビューを忘れているとプルリクエストをマージできず、開発のリードタイムを伸ばしてしまうケースが増えていました。 レビュー負荷が高く、観点の抜け漏れや待ち時間が発生していた 少人数ですべてのプルリクエストをレビューする必要があり、1人あたりのレビュー負荷も高くなっていました。レビュー観点が漏れるリスクに加え、軽微な修正のプルリクエストでも必要なApproveが揃うまで待ち時間が発生していました。 Sentry・Datadog・Splunkのエラー確認が朝会で見切れていなかった Webブロックでは、火曜の朝会でSentry・Datadog・Splunkのエラーやメトリクスを確認しています。しかし、3ツールの情報を朝会の時間内に確認しきれず、対応が必要な問題を見落とす恐れがありました。 KPTのProblemを掘り下げきれず、具体的なTryを出せていなかった KPTの振り返りでProblemが上がっても、原因を掘り下げる時間が足りなかったり、質の高い深掘りができておらず表面的なTryしか出せないことがありました。 課題解決に向けたアプローチ Confluenceに過去のアイデアを蓄積し、AIによるアイデア出しを行う アイデア出しの属人化を解消するため、AIのスキルを作りました。AIがConfluence上のコンテキストと過去のアイデアを参照し、課題・仮説・A/Bテスト案を生成します。その後、AIが各案をスコアリングし、人間が最終的な採否を判断します。 Confluenceには、アイデア出しに使うコンテキストをまとめています。全テーマで使う共通コンテキストと、送客率やアプリダウンロード数などのテーマ別コンテキストを分けて管理しています。さらに、テーマとメンバーごとに過去のアイデアを記録しています。採用しなかったアイデアは、その理由とともに別の一覧へ残し、AIにフィードバックします。 スキルを実行すると、次の手順でアイデアを生成します。 扱うテーマとメンバーを選ぶ 選んだテーマとメンバーに対応するConfluenceページから、過去の採用アイデアと不採用のアイデアを読み込む 全テーマ共通コンテキストと、選んだテーマのテーマ別コンテキストを読み込む 課題・仮説・A/Bテスト案のうち、どれを出力したいかを選ぶ 既存のアイデアと不採用のアイデアを参照し、質が高く重複のない10件のアイデアを生成する 9つの基準でスコアリングし、5点以下のアイデアを除外したうえで、スコアの高い順に最大5件を出力する 手順6では、各基準を満たすアイデアに次の点数を加えます。 No. 評価基準 追加点 1 テーマ・事業成果との接続が強いか 2 2 対象ユーザー数が十分に大きいか 2 3 定量データで裏どりができているか 2 4 ユーザー課題として具体化できているか 2 5 自社で解決できる問題か 1 6 解決したかどうかを測定できるか 1 7 問題の深刻度が高いか 1 8 定性根拠があるか 0.5 9 他のページ・施策にも学びが転用できるか 0.5 人間は出力された上位のアイデアを確認し、採用した案をConfluenceのアイデア一覧に追記します。採用しなかった案は、その理由とともに不採用のアイデア一覧へ記録します。AIは次回の実行時に不採用理由も参照し、クオリティの低い案が出力されないようにします。 レビュー放置をGitHub ActionsとSlack通知で防ぐ PRのレビュー依頼が放置される課題には、GitHub Actionsのワークフローで対応しました。平日の10時・12時・14時・16時・18時(JST)に定期実行し、最新のレビュー依頼から3時間経過しても着手していないレビュアーへSlackでメンションします。 スクリプトでオープン中のプルリクエスト一覧を取得します。次に、各PRのタイムラインAPIから review_requested イベントと reviewed イベントを取得します。レビュアーごとに最新のレビュー依頼時刻を求め、それ以降に approved ・ commented ・ changes_requested のいずれかのレビューがあれば通知しません。 const elapsedHours = Math . floor ( (now. getTime () - new Date (latestReviewRequestedAt). getTime ()) / ( 1000 * 60 * 60 ) ) if (elapsedHours < THRESHOLD_HOURS) return [] return [ `・ ${ buildMention(reviewer) } ${ elapsedHours } h 経過しています。 < ${ pr.html_url } |# ${ pr.number } ${ pr. title} >` , ] レビューを再依頼した場合も、最新の依頼時刻から経過時間を計算します。Draft状態のPRやDependabot・Renovateが作成したPRは通知対象外です。この通知により、レビュー依頼の見落としに気づき、レビュー待ち時間を抑えられるようになりました。 必要なApprove数の動的判定とRenovateの自動マージでレビュー負荷を下げる レビュー負荷を下げるため、3つの仕組みを組み合わせています。 1つ目は、必要なApprove数の動的な判定です。プルリクエストのリスクに応じて、マージに必要なApprove数を1件または2件に設定します。判定には次の3つの仕組みを使います。 パスルールによる判定: .claude/ や .github/workflows/ 、 next.config.* 、 package.json などへの変更は、2件のApproveを必須にする。 AIによる判定:パスルールだけでは判定できない意味的なリスクをAIが評価する。必要なApprove数の引き上げのみを許可し、判定に失敗した場合は2件のApproveを必須にする。 人間による引き上げ:専用ラベルを付けて、必要なApprove数を2件へ引き上げる。1件へ引き下げる操作は用意していない。 この仕組みにより、軽微な変更のPRは1件のApproveでマージでき、2人目のレビュアーを待つ時間を減らせました。 2つ目は、Renovateによる依存関係を更新するだけのプルリクエストの自動Approveと自動マージです。メジャーアップデートではないこと、CIが成功していること、リリースノートがあることを確認したうえでApproveします。 3つ目は、AIコードレビューです。プルリクエストが opened または ready_for_review になったタイミングで、AIのマルチエージェントがレビューします。重要度の高い指摘がなければ、自動でApproveします。 AIレビューは補助として扱います。 .claude/ 配下などレビュー基準を変更するプルリクエストには、人間2名のApproveを必須にしています。これにより、AIレビューの基準を変更するプルリクエストをAIのApproveだけでマージできないようにしています。 これらを組み合わせることで、すべてのプルリクエストを人間がレビューする必要はなくなりました。 ツールごとのAIレポートでエラー確認のコストを下げる 火曜の朝会でSentry・Datadog・Splunkを確認しきれない課題には、スキルで対応しました。このスキルは、 sentry-report ・ datadog-report ・ splunk-report という3つのレポート生成スキルを並列で実行します。その後、結果を1件のSlackメッセージにまとめて投稿します。 各レポート生成スキルは、ツールごとに異なるしきい値を使います。Sentryでは、直近14日間のイベント数が10件以上のIssueだけを調査し、スタックトレースから自社コードに起因するかなど原因を調査します。 Datadogでは、メモリ使用量が前週より50MiB以上増えた状態で高止まりしているかを判定します。さらに、エラーが1時間で100件を超えているか、Core Web Vitalsが1日以上悪化しているかも確認します。 Splunkでは、CloudFrontのログを5つの観点で調査します。たとえば、5xxレスポンスが1時間で100件を超えているか、3xx・4xxレスポンスが前週の同じ時間帯と比べて2倍以上に増えたかを判定します。 しきい値を超えた問題は、リリースやプルリクエストと突き合わせて原因候補を絞り込みます。GitHubのリリース一覧から異常が発生した時間帯のリリースを探し、そこに含まれるプルリクエストの差分から関連しそうな変更を抽出します。 これにより、原因候補を絞り込んだレポートから確認を始められるようになりました。 AIでMiroのKPTボードの議論を掘り下げる KPTの議論を掘り下げきれない課題には、スキルで対応しました。AIがMiroのKPTボードにあるProblem(赤の付箋)とKeep(緑の付箋)を読み、追加の論点を付箋として書き出します。 Problemに対しては、5W1Hを意識して事実関係を整理します。さらに、他チームでの類似事例や、仕組みで防げた可能性などの観点を加えます。Keepに対しては、うまくいった理由が偶然なのか、再現できるのかを掘り下げます。 追加の論点は白い付箋、Tryの提案は黄色い付箋として、元の付箋の近くに配置します。一度に貼る付箋の上限は初期値で4件で、引数で調整が可能です。 これにより、チームはAIが追加した付箋を起点にKPTの議論を始められるようになりました。 取り組みの成果 アイデア創出の負担を抑えられるようになった AIがアイデアの初案を生成するため、人間は候補の評価から作業を始められるようになりました。また、Confluenceに蓄積した過去のアイデアと不採用のアイデアをAIにフィードバックし、同じようなアイデアや質の低いアイデアが提案されないようになり、アイデアのクオリティアップに繋がりました。実際にアイデア出しのブレスト会にて、採用された全アイデア23個のうち、AIに出してもらったアイデアが8個採用されました。 プルリクエストの着手までのリードタイムが縮まった Slack通知で、長時間着手していないレビュー依頼への気づきが早まりました。さらに、AIによる自動ApproveとApprove数の判定により、変更のリスクに応じて人間のレビュー負荷を分散できました。 濃い青の実線がリードタイム。遅い時はマージまで30時間以上かかっていたのが、20時間以内に収まっている。 朝会とKPTで議論から始められるようになった 火曜の朝会では問題の候補や原因と対応要否が整理されたレポートから議論を始められました。KPTではAIが提案した付箋を出発点としてProblemとKeepの深掘りができました。AIにエラーレポートを出力してもらうことで、朝会で都度エラー内容を確認する必要がなくなり、もともと1時間ほどかかっていたエラー確認が20分ほどで済むようになりました。 今後の展望 今後は、AI活用やワークフローをブラッシュアップする予定です。レビューまわりでは、プルリクエストをオープンしてからファーストレビューまでの時間を、さらに短縮できる余地があります。AIスキルの活用では、メンバー各自がスキルを作れる状態にはなったものの、実際にどれだけ活用できているかを把握できていません。活用状況を可視化したうえで、メンバー全体のAI活用レベルを底上げしていきたいと考えています。エラーレポートまわりでは、海外からの大量アクセスが発生した場合の対応をしたいと考えています。また、Sentryのソースマップに関する改善(第三者コードの判定精度の向上、ソースマップが紐づいていない箇所への対応)にも取り組みたいです。 まとめ 本記事では、少人数体制のWebブロックが、AIとGitHub Actionsで開発ワークフローを改善した取り組みを紹介しました。 少人数チームでは、日々の業務のどこかにしわ寄せが生まれがちです。本記事が、同じような課題を抱えるチームの参考になれば幸いです。 おわりに ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、検索基盤部 検索グロースブロックの広渡です。検索グロースブロックでは、ZOZOTOWNのある施策において、ユーザーに表示するコンテンツを複数の候補の中から選んで配信しています。どの候補がユーザーの反応(クリックなどのエンゲージメント)を最も得られるかは事前にわかりません。そこで、配信結果から学習しながら最適な候補に配信を寄せていくMulti-Armed Bandit(MAB)、なかでもThompson Samplingの活用を検討・検証しています。 MABは、広告配信のクリエイティブ選択・検索結果ページに差し込むレコメンドモジュールの出し分け・アプリのホーム画面のコンテンツ最適化など、ECや広告の分野で広く使われているアプローチです。なお、本記事では対象施策の詳細には触れず、この問題設定を前提に話を進めます。 今回の検証の中で課題になったのが、いわゆるコールドスタート問題です。新規に投入した候補は実績データがないため、配信が最適な候補へ収束するまでに時間がかかり、その間はエンゲージメント率の低い候補にも配信が割かれます。その結果、最初から最適な候補を配信できていた場合と比べて、得られるエンゲージメントが少なくなってしまいます。 本記事では、この課題と、過去の実績データを事前分布として活用する解決策を検証した取り組みをご紹介します。 目次 はじめに 目次 背景・課題 事前分布の設計 先行事例 セグメントの粒度の検証 推定方法:モーメント法と最尤推定 事前分布を安全に扱うための工夫 事前分布の強さの制御 少なすぎるデータの除外 効果検証:オフラインシミュレーションからABテストへ オフラインシミュレーションでの事前検証 本番ABテストでの効果測定 まとめ 参考文献 背景・課題 Thompson Samplingは、各候補の「真のエンゲージメント率はどのくらいか」という推測をベータ分布で表現し、配信のたびに次の手順を繰り返すアルゴリズムです。 候補ごとに、そのベータ分布から値を1つサンプリングする もっとも高い値を引いた候補を配信する エンゲージメントの有無を観測し、配信した候補のベータ分布を更新する 実績の良い候補は分布の中心が高くなり、安定して高い値を引くため配信が寄っていきます。一方、データが少ない候補は分布の幅が広いぶん、ときどき高い値を引いて配信の機会を得ます。エンゲージメントが得られやすい候補を「活用」しながら、まだよくわからない候補の「探索」も自然に行われる仕組みです。 ベータ分布はパラメータα、βによって形が決まります。配信を始める前に設定するベータ分布を事前分布と呼びます。新規に投入した候補に対して一様分布のBeta(1, 1)を事前分布として使う場合を考えます。Beta(1, 1)は、エンゲージメント率が0%から100%までのどの値であるかをすべて等しくありうるとみなす分布で、いわば「何もわかっていない」状態を表します。 Beta(1, 1)が前提とする分布は、実際のエンゲージメント率(多くはごく数%程度に集中する)とはかけ離れています。次の図は、Beta(1, 1)と、実際のエンゲージメント率の分布に合わせて過去の実績データから推定した事前分布(推定方法は後述します)の形状を比較したものです。 事前分布が実際のエンゲージメント率とかけ離れていると、Thompson Samplingは最適な候補を見つけるまでに多くの試行を必要とします。この間はエンゲージメント率の低い候補も一定の頻度で配信されるため、最適な候補を最初から配信できていた場合と比べてエンゲージメントを取りこぼすことになります。この差はリグレットと呼ばれます。こうした事前分布の不一致による損失は、 Russo, D. J. et al, 2018 1 でも指摘されている問題です。 そこで、無情報な事前分布の代わりに、過去の実績データを使って推定した事前分布を使うことでコールドスタート期間の収束を早められるか検証しました。 事前分布の設計 ここからは先行事例を交えながら、事前分布の推定方法を具体的に説明します。 先行事例 過去の実績データを事前分布に活かした先行事例を3つ紹介します。 Walmart Global Tech Blog 2 では、広告配信において過去の実績から求めた初期パラメータを事前分布として使っています。 Learn Thompson Sampling by Building an Ad Auction 3 では、過去の広告キャンペーンの実績データに最尤推定でベータ分布をフィットさせる例が紹介されています。同様に、前述のRusso, D. J. et al, 2018でも、過去の広告をスタイルが似たグループに分割し、グループごとの実績データの分布を事前分布として使うアプローチが例として挙げられています。 こうした先行事例を踏まえ、私たちも過去の実績データを事前分布に反映するアプローチを採用することにしました。過去の実績データから事前分布Beta(α, β)を推定するまでの流れを次の図に示します。 図は処理の実行順です。本文ではこれと異なり検討した順序に沿って、まずデータの区切り方(セグメントの粒度)と推定方法の2点を説明し、その後「事前分布を安全に扱うための工夫」として強さの制御とフィルタ処理を説明します。 セグメントの粒度の検証 事前分布は、すべての候補をまとめて1つ推定する方法だけでなく、いくつかの候補の属性(例えばブランドやカテゴリーなど)でセグメントごとに分けて推定する方法も考えられます。セグメントを細かく分けるほど各候補の特性に近い事前分布が得られる一方、セグメントあたりのデータ数は減り、推定は不安定さを増すというトレードオフがあります。 そこで、直近の一定期間の実際のトラフィックのログを使い、候補の属性でセグメント分けした場合のエンゲージメント率の差を、属性の組み合わせごとに確認しました。セグメント間の差が明確な切り方ほど、セグメントごとに事前分布を分けて推定する価値があるためです。確認の結果、差の出方は切り方によって大きく異なりました。単一の属性で区切っただけでは差がわずかである一方、複数の属性を組み合わせると明確な差が現れる切り方もありました。 この結果を踏まえ、実データで差が確認できた属性の組み合わせを事前分布のセグメントとして絞り込みました。 なお、本記事で扱うバンディットは、同じ枠で競合する候補が必ず同一セグメントに属するように設計されています。そのため、同じセグメントに属する候補はいずれも同じ事前分布から出発し、事前分布を変えても候補間の初期の選ばれやすさに差がつくわけではありません。事前分布が担うのは候補間の優劣づけではなく、探索を始める際に想定するエンゲージメント率の水準を、実態に近づけることです。 推定方法:モーメント法と最尤推定 ベータ分布のパラメータをデータから推定する方法として、モーメント法と最尤推定の2つを検討しました。 モーメント法は、セグメント内の候補のエンゲージメント率の平均μと分散σ²から、次の式でα、βを解析的に求める方法です。 $$\alpha = \left(\frac{\mu(1-\mu)}{\sigma^2} - 1\right) \mu$$ $$\beta = \left(\frac{\mu(1-\mu)}{\sigma^2} - 1\right) (1-\mu)$$ 分散σ²が分母にあるため、セグメント内のエンゲージメント率の分散が小さいほど、推定されるα+βは大きくなります。この性質は、後述する事前分布の強さの制御とフィルタのしきい値の調整に関わってきます。 最尤推定は、ベータ分布の尤度を最大化するα、βを数値的に求める方法です。エンゲージメント率が0や1になる候補が存在すると尤度の計算が発散するため、実装ではエンゲージメント率を境界からわずかに離すスケーリングを行いました。 from scipy.stats import beta n = len (rates) rates_scaled = (rates * (n - 1 ) + 0.5 ) / n alpha_hat, beta_hat, _, _ = beta.fit(rates_scaled, floc= 0 , fscale= 1 ) モーメント法は計算が軽く実装も簡単な一方、外れ値の影響を受けやすいという特徴があります。最尤推定は計算コストが高いものの、外れ値に対して比較的頑健です。後述するオフラインシミュレーションでの比較検証の結果、最終的に最尤推定を採用しました。 事前分布を安全に扱うための工夫 セグメントと推定方法の方針が固まったところで、2つの工夫を導入しました。 事前分布の強さの制御 ベータ分布の更新は、エンゲージメントが得られたらαに1を、得られなければβに1を加える操作です。したがって事前分布のα+βが大きいことは、配信を始める前からすでにα+β回の配信を観測し終えているのと同じ意味を持ちます。このような事前分布を、強い事前分布と呼びます。事前分布が強すぎると、実際の観測を重ねても各候補のベータ分布の平均はわずかしか動かず、推測がそれぞれの候補の実際のエンゲージメント率に近づくのが遅れてしまいます。過去のエンゲージメント率のばらつきが小さいセグメントでは推定されるα+βが極端に大きくなることがあります。 そこで、α+βの上限をCとし、α+βがCを超えた場合に平均μ=α/(α+β)を保ったままα、βを同じ比率でスケールダウンする補正を加えました。α、βを共通の係数でスケールし、平均を変えずに強さだけを調整するという操作自体は、既存の研究でも使われています。 Chapelle and Li, NeurIPS 2011 4 の「Posterior Reshaping」はその一例です。同論文では、分散を小さくして探索を抑える方向のスケールが短期的にはリグレットを下げる一方、試行によっては大きなリグレットが生じるというトレードオフを報告しています。私たちは探索が損なわれるのを防ぐことを優先し、強くなりすぎた事前分布の分散を広げる方向でスケールしています。 $$\text{scale} = \frac{C}{\alpha + \beta}$$ $$\alpha' = \text{scale} \times \alpha$$ $$\beta' = \text{scale} \times \beta$$ 少なすぎるデータの除外 セグメント内の候補の数や候補の配信数が少ないと、エンゲージメント率が0や1に偏りベータ分布へのフィットが悪化します。そこで、一定以上の配信数を持つ候補のデータのみを事前分布の推定に使うフィルタを導入しました。また、絞り込んだ結果セグメント内の候補の数が少なすぎる場合は、より粗いセグメントにフォールバックする仕組みも合わせて導入しました。 このフィルタを強めるほど残るデータの質は上がる一方、対象となる候補が減ってエンゲージメント率の分散が小さく見積もられやすくなり、α+βが大きくなりやすくなります。そのため、α+βの上限補正と合わせた調整が必要になります。 効果検証:オフラインシミュレーションからABテストへ 推定方法・セグメントの粒度・事前分布の強さの上限・フィルタのしきい値については、それぞれ複数の候補を用意しました。過去の実績データから推定した事前分布をABテストとして配信する前に、まずオフラインシミュレーションでリグレットを直接比較し、その結果をもとにABテストで効果を確認しました。ABテストではリグレットを直接測れないため、収束の速さを代理指標として評価しています。ここでいう収束とは、最良の候補への配信比率が一定の水準に達した状態を指します。オフラインとABテストでは指標も測定方法(DM法による仮想的な再現と実際のトラフィック)も異なるため、それぞれで示す改善率は単純に比較できない点に注意してください。 オフラインシミュレーションでの事前検証 各候補を均等な確率でランダムに配信して集めたログを用いて、実測エンゲージメント率をもとにアルゴリズムが得られたはずのエンゲージメントを仮想的に再現しました。この手法は、過去のログから学習した報酬モデルを使って別の方策が選んだであろう行動の報酬を推定する、Direct Method(DM法)と呼ばれるオフライン評価手法に近いシミュレーションです。対象の候補に対し、推定方法・セグメントの粒度・事前分布の強さの上限・フィルタのしきい値を組み合わせた複数の設定をそれぞれ複数回試行しました。一様分布のBeta(1, 1)をパターンAとし、過去の実績データから推定した複数の事前分布の設定と比較したところ、いずれの設定でもパターンAを上回る結果になりました。以降では、このうち採用した設定をパターンBと呼びます。 パターンBでは、リグレットをパターンA比で約13%改善し、最適な候補への収束に必要な配信数も12〜17%ほど削減できる見込みが得られました。累積リグレットの推移を次の図に示します。 この結果を踏まえ、本番でのABテストに進むことを決めました。 本番ABテストでの効果測定 ABテストでは、一様分布のBeta(1, 1)をパターンA、過去の実績データから推定した事前分布をパターンBとして比較しました。収束の速さを複数の独立した方法で確認したところ、いずれもパターンBが一貫してパターンAを上回りました。具体的には、最良の候補への配信が集中する速さで見ると、パターンBは同じ収束水準に到達するまでに必要な配信数を、パターンAと比べておよそ15〜25%削減できました。ビジネス上の主要な指標にも有意な差は見られませんでした。今回の検証はコールドスタート期間の収束速度の改善を主眼としており、ビジネス指標については悪化がないことを確認するガードレールとして位置づけています。 まとめ 本記事では、MABのコールドスタート対策として、過去の実績データから事前分布を推定し、一様分布Beta(1, 1)と比較検証した取り組みを紹介しました。過去の実績データを用いた事前分布によって、オフラインシミュレーションとABテストの両方で最適な候補への収束を早められることを確認しました。 今後は、セグメント単位ではなく候補ごとにエンゲージメント率を予測するモデルを用意し、その予測値を初期値とする、より個別化された事前分布の推定方法を検討していきたいと考えています。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com 参考文献 Russo, D. J., Van Roy, B., Kazerouni, A., Osband, I., & Wen, Z. (2018). A Tutorial on Thompson Sampling. Foundations and Trends in Machine Learning, 11(1), 1-96. ↩ Kasireddy, S. (2020, January 14). CTR Optimization via Thompson Sampling. Walmart Global Tech Blog. ↩ Kurt, W. (2020, September 28). Learn Thompson Sampling by Building an Ad Auction! countbayesie.com. ↩ Chapelle, O., & Li, L. (2011). An Empirical Evaluation of Thompson Sampling. In Advances in Neural Information Processing Systems 24. ↩
はじめに こんにちは。技術戦略部CTOブロックの ikkou です。ZOZOでは毎年、独自の新卒研修を実施しています。さらに昨年からは、日本CTO協会の新卒エンジニア合同研修にも参加しています。参加は任意として、興味を持つ研修を自身で選択できるようにしました。本年度は2名の新卒エンジニアが参加しました。本記事では参加者によるレポートをお伝えします。 目次 はじめに 目次 日本CTO協会の新卒エンジニア合同研修とは 第1回:関係の質を上げるファシリテーション 第2回:Google Cloudで実現するクラウドネイティブ・アーキテクチャ 第3回:エンジニアの働き方とキャリア論 第4回:Codexを使った開発 第5回:GMOペパボによるサーバー解体研修 第6回:Alibaba Cloudから学ぶクラウドの基礎 第7回:合同ISUCON研修 印象に残った学び 業務・キャリアに活かしたいこと まとめ 日本CTO協会の新卒エンジニア合同研修とは 日本CTO協会は、企業の技術責任者が集まり、日本のDX(デジタルトランスフォーメーション)とCX(コーポレートトランスフォーメーション)の推進を目指す団体です。新卒エンジニア合同研修は、その会員企業の新卒エンジニアを対象とした取り組みで、2024年度に始まり、今年で3年目を迎えました。1社では用意しきれない学びの機会を企業の枠を越えて持ち寄り、業界全体で新卒を育てるという趣旨の研修です。 2026年度は全7回で、回ごとに異なる会員企業が講義を担当し、会場もその企業のオフィスへと変わります。扱われた領域は、ファシリテーションからクラウド、キャリア、コーディングエージェント、物理サーバー、パフォーマンスチューニングまで多岐にわたりました。参加者は各社の新卒エンジニア約100名で、講義だけでなくグループワークや懇親会を通じて他社の同期と交流できるのも、この研修の特徴です。 第1回:関係の質を上げるファシリテーション 第1回は北島が担当いたします! 今回は、株式会社メンバーズさんの神尾武志さんによる「関係の質を上げる」ためのファシリテーションを学ぶ講義で、最後にグループワークを実践するという研修でした。 ファシリテーションとは「人々の活動が容易にできるよう支援し、うまくことが運ぶよう舵取りすること」と定義されています。そのために必要なのが参加者同士の関係の質、つまり心理的安全性や相互理解です。この土台を作るのがファシリテーターの役割だという話でした。 具体的な立ち回りとして「伝える・場を見る・働きかける」の3つが紹介されました。 まず「伝える」では、参加者はファシリテーターより情報が少ない状態でその場に来ます。はじめにアジェンダ・趣旨・ゴールを伝えて心の準備をしてもらうことで、参加者が安心して場へ入れるようになります。途中でまとめを挟んで「今どこにいるか」を共有するのも、透明性を保つための大切な作法です。 次に「場を見る」では、参加者同士が議題を介して自走できているかを観察します。うまくいっていないなら介入し、回っているなら手を引く。どちらの判断も「働きかけ」であり、ファシリテーターが場全体を俯瞰して舵取りする姿勢が関係の質を守ります。 最後に「働きかける」では、問いの設計と仕組みの設計が肝でした。「質問ありませんか?」は答えにくく、「どちらかといえば?」「一言だけ感想を」のようにクローズな問いや小さな問いから入るほうが参加しやすくなります。発言しない人に対しても「やる気がない」と捉えるのではなく、事前に書いてもらう、その場で書く時間を取るなど、場の設計で引き出せるという視点は新鮮でした。 グループワークは「懇親会でもっとお互いを知りたくなる自己紹介を作る」がテーマでした。 私のグループは「話したいエピソードを選んで話す」というお題にし、私はファシリテーターを担当しました。 エンジニアならではのエピソードを中心に、お題を自由に選べる形式にしました。その結果、心理的安全性が保たれ、相互理解も自然に進みました。共通点が見えた瞬間に場の雰囲気はぐっと変わり、ファシリテーションの効果を実感しました。 第2回:Google Cloudで実現するクラウドネイティブ・アーキテクチャ 第2回も北島が担当いたします! 今回は、Google Cloudさんによる「次世代のクラウドネイティブ・アーキテクチャ」をテーマにした講義でした。 コンピュートは仮想マシン → コンテナ → サーバーレスという流れで進化しており、今の新規開発ならCloud Runが主流だという話でした。コンテナを渡すだけでデプロイでき、アクセスがなければ料金もかかりません。このシンプルさが強みです。 データベースもAI目線で見ると選び方が変わってきていて、ベクトル検索に対応しているAlloyDB(PostgreSQL互換の高性能版)が今注目されているとのことでした。 その流れで出てきたデータ基盤の話に、最も関心を引かれました。「統合・品質・可読性」の3条件が揃っていないと、AIエージェントは正しく動かないという話でした。散在データを集めて正確にし、人間とエージェントの双方が理解できる形にします。SREが「信頼性」と向き合うときの考え方と同じだと感じました。 こうしたデータ基盤の上に乗るのがGeminiです。テキスト・画像・動画をまとめて処理できるマルチモーダル性が他社との違いで、さらにGemini Roboticsとして映像・音声・物理動作を統合した身体性AIへの展開も紹介されていました。AIが「考える」から「動く」へ変わっていく様子には、少しゾクっとしました。 全体を通して伝わってきたのは、Google Cloudの強みがデータ基盤とGeminiを同じプラットフォームでつなげられる点にあり、AI活用の本質は技術より先にデータを整えることにあるということです。 懇親会ではGoogleのサービスに関するクイズがありました。結果は100人中9位でした。 第3回:エンジニアの働き方とキャリア論 第3回は、佐藤が担当いたします! 今回は、株式会社LayerX代表取締役CTOの松本勇気さんによる発表でした。テーマはエンジニアの働き方とキャリアです。ご自身の経験を交えながら、「キャリアを投資として考える」「フォロワーシップとマネジメント」「LLM時代のエンジニアのキャリア論」という三部構成でお話を伺いました。 第一部のテーマは、キャリアを投資家の視点で捉えるという考え方です。自分が持っている時間、体力、知識、経験、信用、これらはすべて資産であり、キャリアとはその資産を運用し続けるプロセスだと説明されました。そうであれば、投資と同じようにバランスシートやポートフォリオを意識できるはずです。手元の資産をどこへ配分し、どんなリターンを期待するのかを常に考える、という発想です。 興味深かったのは、リスクの取り方についての話です。大きな挑戦にはレバレッジが効く一方で、当然ながら失敗する可能性もあります。しかし、その経験自体も知識として蓄積され、次の判断の精度を上げてくれます。何もしないという選択にもリスクとリターンがある、という指摘には考えさせられました。もう1つ強調されていたのが、周囲のコミュニティです。友人、一緒に働く上司や同僚、社外のエンジニアコミュニティなど、どのような人たちと時間を共にするかが、そのまま自分の成長につながります。コミュニティを充実させること自体が、資産を増やすための投資の1つだといえます。 第二部では、チーム開発とマネジメントについての話がありました。そもそも、なぜチームで開発するのでしょうか。1人で開発すれば意思決定は速く、他人への配慮も要らないため、効率のよい面もあります。それでもチームを組むのは、大規模なものを作るには人数が必要だからです。1人では視点や考慮も不足します。そのうえでチームの効率を落とさないために欠かせないのが、マネジメントだと説明されました。 ここで出てきたのがフォロワーシップという考え方です。マネージャーも完璧ではありません。だからこそメンバー側から自己開示をしたり、自分の考えを言語化して伝えたりすることで、マネージャーが動きやすい状況を作れます。マネジメントされるのを待つのではなく、こちらからも働きかけていくという姿勢です。この話は個人的にとても面白く聞きました。マネジメントは上司から一方通行で受け取るものだと思っていたのですが、実際にはメンバー側からフォロワーシップという形で応えられます。働くうえで自分たちが楽しいと感じられる状態を作るために、こちらから動く余地があるという視点は、これまで持っていなかったものです。 第三部のテーマは、LLMの登場によってエンジニアのキャリアがどう変わるのかです。結論として示されたのは、デザイナーやエンジニアといった職種の垣根が今後どんどん低くなるという見立てでした。LLMを使って設計からデザイン、実装までを一人で担います。そうした働き方が当たり前になっていくという話です。ここで出てきたキーワードがAI builderです。LayerXでは、この言葉を実際に使っているそうです。 では、新卒は何をすればよいのでしょうか。挙げられていたのは、まず師匠を探すことでした。ロールモデルになる人や、優秀だと思える上司の近くで学ぶ価値は大きいという話です。もう1つが、軸をずらして戦うという戦略です。経理とエンジニアリング、営業とエンジニアリングのように、本来は別々だった職種を掛け合わせて自分の立ち位置を作るという考え方です。掛け合わせが差別化になるという指摘は、今後のキャリアを考えるうえで役に立ちそうだと感じました。 第4回:Codexを使った開発 第4回も佐藤が担当いたします! 今回はオンラインでの開催で、OpenAIでCodexの開発に携わる方による講義でした。前半はCodexの使い方の解説、後半は実際にCodexでアプリを作るハンズオンという構成です。 はじめに扱われたのが、コンテキストとは何かという話でした。コンテキストはエージェントのワーキングメモリであり、そこに何をどれだけ載せるかがエージェントの振る舞いを決めます。コンテキストウィンドウには限りがあるため、必要な情報を必要なだけ渡す設計が重要です。あわせて、SkillsとMCPが何を担うものなのか、両者の違いについても解説がありました。 講義でとくに時間が割かれていたのが、Skillsの作成です。面白かったのは、スキルを作るためのスキルが用意されている点でした。作りたい処理の要件をエージェントと対話しながら整理し、改善を重ねて1つのスキルに落とし込んでいきます。手続きを頭の中に置いたまま毎回プロンプトで説明するのではなく、繰り返す作業をスキルとして切り出していく進め方です。さらに、他の人が使っているスキルを自分のCodexでもそのまま動かせるように環境を整える、という話もありました。個人の工夫で終わらせず、チームで共有できる資産にしていく発想です。 複数のエージェントを並行して動かす場合は、それぞれに必要な処理をどう組み込むかが課題になります。ここで紹介されたのが、ツールを実行するタイミングの設定でした。ある処理の前に動かすもの、後に動かすもの、セッションの開始時に動かすものといった設定を組み合わせることで、エージェントが動く土台そのものを設計できます。いわゆるハーネスエンジニアリングにあたる考え方だと理解しました。 登壇者が強調されていたのが、Auto-reviewの機能です。Codexでは、エージェントが操作できる範囲を定めるサンドボックスと、範囲外の操作をする際の承認方法を組み合わせて設定できます。通常の設定では、ワークスペース内の一般的な操作はそのまま実行でき、設定された境界を越える場合に人間へ承認を求めます。Auto-reviewを有効にすると、こうした承認要求の一部を別のレビューエージェントが審査し、その判定に応じて実行を制御します。人間が逐一確認する手間を減らしつつ、無制限に権限を渡すリスクも避けられる仕組みだといえます。このほかにも、デフォルトの設定の考え方、プランモードの使い方、画像を渡した開発の進め方など、実践的なTipsを数多く紹介いただきました。 講義の後半は、実際にCodexを使ったアプリ開発です。私は、自社のキャラクター(箱猫マックスくん)を題材にしたポモドーロタイマーの拡張機能を作りました。作業と休憩の時間を管理するシンプルなツールです。 驚いたのは、そのスピードでした。作りたいものの要件を伝えてから、動くところまでこぎつけるのにかかった時間は10分ほどです。完成したものが次の画面で、集中する時間をカウントダウンし、残り時間に応じてキャラクターがバーの上を進んでいきます。 この規模のアプリケーションであれば、思いついたその場で形にして試せます。わざわざ作るまでもないと諦めていたアイデアも、まず動かしてから判断できるようになります。手元の道具をこうして気軽に作れるという感覚は、講義を聞くだけでは得られないものでした。 普段からコーディングエージェントは使っていましたが、開発している当人から設計の意図を聞けたのは貴重な機会でした。エージェントにどこまで任せ、どこを人間が握るのかの線引きを、権限の設定という具体的な形で考えられるようになったのが一番の収穫です。 第5回:GMOペパボによるサーバー解体研修 第5回は北島が担当いたします! 今回は、GMOペパボさんによる物理サーバーの話でした。 クラウド全盛の今でも物理サーバーには独自の強みがあるという話は、新鮮に感じられました。コストはクラウドより安く抑えられ、メガクラウドや為替レートにも左右されません。クラウドやAIであっても「末端は物理ハードで動いている」という言葉には、強く納得しました。デジタルガジェットとして楽しい、という言い方も好きでした。 ただ管理の大変さも正直に話していただきました。ラッキングやケーブリング、部品交換、温湿度・入退室管理、深夜のトラブル対応など、クラウドなら画面の操作で済むところが、すべて自前になります。スペック選定もシビアで、買ったら変更できない分、過剰でも不足でも損をします。寿命も3〜5年で、老朽化したら計画的なリプレースとデータ移行が必要になるという話をしていただきました。 その話を聞いた後で、実際にサーバーを解体する体験がありました。CPU、ECCメモリ、ストレージ、マザーボード、冗長電源、NIC、冷却ファン、そしてOSが止まっていても遠隔操作できるリモート管理カード(iLO・iDRAC)まで、構成要素を一通り手で触りました。話で聞いていた「管理が大変」の意味が、実物を目の前にすると一気に腑に落ちました。 普段はKubernetesやクラウドの抽象レイヤーの上で仕事をしているので、こうして物理を触る機会はなかなかありません。重さがあり、熱を持ち、騒音も出します。そうした実体のあるコンピューターと向き合うと、インフラへの解像度は一段上がった気がしました。 第6回:Alibaba Cloudから学ぶクラウドの基礎 第6回は佐藤が担当いたします! 今回のテーマはAlibaba Cloudです。中国の巨大ECを運営するアリババグループ傘下のクラウド事業会社が提供するパブリッククラウドサービスです。欧米含めグローバルに30リージョン・104のアベイラビリティゾーンを展開しており、アジア太平洋地域では1位、世界では4位という立ち位置にあると紹介されました。中でも特にアジア市場に集中的に投資をしており、中国をはじめとするアジア市場との結びつきが強いのも特徴といえます。講義はクラウドの基礎から始まり、後半はAI時代に向けたクラウドの話へと進んでいきました。 まず扱われたのが、クラウドとオンプレミスの違いです。自社でサーバーや機器を購入して設置、管理するのがオンプレミス、プロバイダーが管理するインフラをネットワーク越しに借りるのがクラウドという整理でした。クラウドのメリットとしては、負荷に応じてリソースを増減できるスケーラビリティと、使った分だけ支払えばよいコスト効率が挙げられます。 面白かったのは、この違いを会計の視点から捉え直す話です。オンプレミスはサーバーという資産を購入するため、初期に大きな投資が発生します。これがCapEx、資本的支出です。対してクラウドは月々の利用料として支払うため、発生した期に費用として計上されます。こちらがOpEx、運用費用にあたります。CapExでは、ピーク時の負荷を事前に予測して設備を購入しなければなりません。予測が外れれば過剰投資になり、逆に足りなければ機会損失につながります。OpExであれば需要に応じてコストが変動するため、事業の変化に追従しやすくなります。クラウドが選ばれる理由を技術面だけでなく経営面から説明されると、腹落ちの度合いが違いました。 続いて、どこまでをプロバイダーに任せるかによる分類、クラウドサービスモデルの話です。まずは、仮想マシンやネットワークなどインフラ環境を提供するIaaSがあります。そのうえに、アプリケーションの実行基盤まで用意するPaaS、アプリケーションそのものを提供するSaaSと続きます。プロバイダーに任せる範囲が広がるほど、利用者が管理する範囲は狭くなります。さらに近年は、AIの大規模モデルをサービスとして利用できるMaaS(Model as a Service)も加わってきているという話がありました。クラウドの分類そのものが、AIの登場によって更新されつつあるということだと理解しました。 構成の話として紹介されたのが、ハイブリッドクラウドとマルチクラウドです。ハイブリッドクラウドは、パブリッククラウドとプライベートクラウドやオンプレミスを組み合わせる構成を指します。一方のマルチクラウドは、AWSとGoogle Cloudのように複数のパブリッククラウドを組み合わせる構成です。ベンダーロックインを避けられる、それぞれの強みを活かせるといった利点がある反面、運用や監視が各クラウドで異なるため複雑になるという課題もあります。この話は、自分の業務と結びつけて聞けました。ZOZOでも複数のクラウドを併用しており、普段はその上で開発をしています。当たり前のものとして使っていた構成に名前と理由がついた感覚です。なぜこうなっているのかを考える良いきっかけになりました。 講義の終盤で取り上げられたAIとセキュリティの話には、特に興味を引かれました。AIの普及によって、必要な電力量やリクエストの量が急増し、AIのためのデータ基盤も求められるようになっています。そうした変化のなかで、AI時代のクラウドのセキュリティをどう担保するかに力を入れているという話でした。インフラや資源に強みを持つからこそ描ける展望があるのだと感じました。 第7回:合同ISUCON研修 第7回は北島が担当いたします! 今回は株式会社PR TIMESさんによるISUCON研修 *1 です。Webアプリケーションのパフォーマンスチューニングに取り組みました。チーム戦ではなく個人戦で、AIをどう活用できるかが問われる内容でした。 GETのインデックス最適化、DBのJOIN最適化、POSTの非同期処理と順番にチューニングし、結果は90人中7位でした。スコアが上がるたびに手応えはありましたが、40万点あたりで完全に詰まってしまい、1位の60万点には遠く及びませんでした。 壁にぶつかって気づいたのが、「計測が先、最適化は後」とするべき順番を逆にやっていたということです。アクセスログを集計してどのエンドポイントが遅いかを可視化し、スロークエリを洗い出してからボトルネックを潰していくという順番が正解です。自分は「これが遅そう」という勘で先に手を動かしていました。やったこと自体は間違いではありませんが、どこに時間を使うべきかが見えていないままでした。 AIの使い方も、同じ話でした。後半で引き出しが尽きてきたとき「なんか遅い気がするんだけどどうすればいい?」のような質問をしてもスコアはまったく伸びませんでした。AIは事実ベースで動くため、感覚を渡しても答えは出てきません。「このエンドポイントのレイテンシが〇ms出ている、原因として何が考えられる?」という具体的な数字があってはじめて、有効な回答が返ってきます。 裏を返せば、わからないことやぼんやりした概念はそのままにせず、AIに聞いて解消してしまうのが一番いい使い方です。AIには納得がいくまで質問でき、聞けばすぐ答えてくれます。ぼんやりしたまま放置する理由はありません。事実をつかみ、わからないことをなくしていくのがAI時代の学び方だと思いました。 印象に残った学び 全7回を振り返ると、扱われた領域はキャリアからインフラ、AIまで幅広いものでした。それでも通して聞くと、回をまたいでつながる話がいくつもあります。2人に共通して印象に残ったのは、次の3つです。 1つ目は、AIとの向き合い方が具体的な設計の問題として語られていたことです。第4回では、コマンドの実行権限をどう設定するか、どのタイミングでどのツールを動かすかという形で、エージェントが動く土台そのものを設計する話がありました。第7回では、感覚を投げても答えは返ってこず、具体的な数字を渡してはじめて有効な回答が得られるという経験をしました。第3回で出てきたAI builderという言葉も、AIを前提に自分の仕事の範囲を引き直す姿勢を指しています。AIを使いこなすというと、うまい指示を出す技術の話に聞こえがちです。しかし研修を通して見えてきたのは、どこまでを任せ、何を渡し、どこで人間が判断するのかを決める設計の話でした。 2つ目は、抽象化されたレイヤーの下には実体があるということです。第5回のサーバー解体は、普段の開発では意識しない層に触れる機会でした。第2回と第6回でクラウドの話を聞いた後だったこともあり、抽象化されたサービスの下には物理的な実体があるという当たり前の事実を、実感を伴って理解できました。第6回で出てきたCapExとOpExの話も、同じ方向を向いています。クラウドを使うというのは、本来自分たちが抱えるはずだったコストとリスクを、誰かに預けるという判断です。何が抽象化されているのかを知っていれば、預けている範囲も、自分たちが引き受けるべき範囲も見えてきます。 3つ目は、場や関係性も設計できるという視点です。第1回のファシリテーションと第3回のフォロワーシップは、扱っている題材こそ違うものの、根っこは同じでした。参加者が発言しないのは、やる気がないからではなく場の設計に理由があるのかもしれません。マネジメントがうまく回らないのは、マネージャーだけの問題ではなくメンバー側から働きかける余地があるのかもしれません。どちらも、目の前の状況を所与のものとして受け取らず、自分の側から設計できると捉える見方です。チームで働くとき、技術と同じくらい実践的な考え方だといえます。 業務・キャリアに活かしたいこと 私たちは配属されたチームが異なり、日々向き合っている技術も違います。それでも研修を終えて、共通して持ち帰りたいと考えたことが3つありました。 1つ目は、計測を先に置くことです。第7回で痛感したとおり、勘を頼りに手を動かしても、どこに時間を使うべきかは見えてきません。改善の施策を考える前に、何が起きているのかを数字で押さえることが重要です。当たり前でありながら、忙しくなるほど飛ばしてしまいがちな手順だと反省しました。 2つ目は、エージェントに任せる範囲の設計です。日々の開発でコーディングエージェントを使う場面は増えていますが、その多くは個人の工夫にとどまっています。繰り返す作業をスキルとして切り出し、チームで共有できる形にしていくことは、今すぐ始められる改善だと感じました。 3つ目は、チームへの働きかけです。ミーティングの進行や他チームとの相談の場面で、自分から場を作れるようになりたいと考えています。関係の質が土台にあるという第1回の話は、そのまま日々の仕事に持ち込めるものでした。 キャリアという長い時間軸で見ると、第3回の軸をずらして戦うという話が心に残っています。新卒の今は、複数の領域を掛け合わせられる場所に自分を置いていくための資産を積む時期なのだと考えています。 まとめ 全7回を終えて振り返ると、扱う領域は毎回まったく違っていたにもかかわらず、繰り返し語られていた話題があります。AIによって仕事がどう変わるのか、という問いです。 各回で語られていたのは、たとえば次のような話です。 職種の垣根は低くなり、1人が担える範囲は広がっていく エージェントに任せる範囲を決め、動く土台を整えることが仕事になる データが整っていなければAIは力を発揮できない 感覚ではなく事実を渡してはじめて、有効な答えが返ってくる それぞれ別の会社の、別のテーマの講義でしたが、いずれも同じ変化を違う角度から説明しているように聞こえました。 共通していたのは、AIが人の仕事を奪うという語り口ではなかったことです。むしろ、手を動かす部分をAIが担えるようになったからこそ、何を任せて何を自分で判断するのかを決める仕事の比重が増していくという見立てでした。新卒としては不安を覚えてもおかしくないテーマですが、研修を終えた今は、自分から動かせる余地の大きさのほうを強く感じています。 そしてもう1つ、この研修ならではの価値が、他社の同期と過ごす時間でした。同じ年に社会人となった人たちが、それぞれ違う環境で何を考えているのかを知ることができました。グループワークや懇親会で交わした会話は、講義とは別の刺激になりました。来年以降に参加する方にも、ぜひこの時間を楽しんでもらえたらと思います。 ZOZOでは、新卒・中途に限らず、一緒にサービスを作り上げてくれる方を募集中です。ご興味ある方は以下のリンクからぜひご応募ください。 corp.zozo.com *1 : 「ISUCON」は、さくらインターネット株式会社の商標または登録商標です。
はじめに こんにちは、コーポレートエンジニアリング部のてぃーです。普段はGoogle WorkspaceやBox、SlackといったSaaS製品のアカウント管理や運用改善を担当しています。 当社では、Googleドライブを運用する中で生じていたセキュリティ面・ガバナンス面の課題や、利用者からのMicrosoft系ファイルの機能要望を解決するため、Boxを導入しました。 しかし、ただBoxを導入しても、業務データがBox上になければ課題は解決できません。そこで私の所属する部署では2024年5月より、GoogleドライブからBoxへのデータ移行に取り組んでいます。 一般的に、異なるシステム間のデータ移行はファイル形式の崩れや権限の欠落といったトラブルを招きやすいため、慎重に進める必要があります。特に、Googleドライブは他のストレージサービスと比較して独自の仕様や機能が多くあり、そこからの移行は困難を極めます。 それに対し、Box Shuttleスタンドアローン版を駆使し様々な工夫と現場の皆さんの多大な協力により、なんとかBox移行を実施しました。 本記事ではその取り組みをご紹介します。 目次 はじめに 目次 背景・課題 Google系ファイルをそのまま移行する方法の検討 フォルダ構成の設計 マイドライブのデータ整理方針 ユーザーのアクセス権限移行 管理者 → 共同所有者 について 閲覧のみ可(コメント可) → 編集者 について グループのアクセス権限移行 BoxとGoogleドライブの並行運用 ユーザーとの調整について Box移行担当者のアサイン 事前確認環境の用意 移行の効果 プロジェクト全体について Box移行に関するBox移行担当者アンケート アンケートの総括 今後の改善案 AIに関するGoogleドライブ利用ケースの拡大 Box for Google Workspaceの連携強化への期待 Googleドライブの外部共有ルール・制限設定の改善 まとめ 背景・課題 当社ではGoogleドライブを運用する中で、次の課題が生じていました。 バックアップを破壊する機能を持つランサムウェアの被害が発生した際に、正常な状態へ確実に復旧することが難しかった マイドライブは個人管理下にあるため、組織として権限やデータ状況を横断的に把握・統制することが難しかった 外部共有の制御が共有ドライブ単位に限られ、データの格納先が分散し、外部共有の運用が利用者個人の判断に委ねられていた ExcelなどMicrosoft系ファイルにある独自機能を利用したいという要望が多く寄せられていた これらの課題をクリアにしつつ、GoogleドライブからBoxに大量のデータを移行し、かつスムーズな運用移行を成功させるには、次を実現する必要がありました。 Googleドライブのほとんどのデータを移行対象とし、大量データを可能な限り失敗なくファイル形式を崩さず移行すること(特にGoogle系ファイルの移行に懸念がありました) 移行に伴い現在のアクセス権限をそのまま引き継ぐこと(特に過剰付与が危険でした) 外部との共有では、移行初期の不慣れによる事故を最小限にしつつ、スムーズに運用を再開できるようにすること Boxでの理想のアクセス制御の仕様や考え方と、現行のGoogleドライブの運用ルールとの乖離による業務影響のバランスが取れた運用ルールを作成すること Google系ファイルをそのまま移行する方法の検討 Boxが標準で提供する移行ツールである Box Shuttle や、サードパーティ製の移行ツールを調査しました。その結果、いずれもGoogle系ファイル(Googleドキュメント・スプレッドシート・スライドなど)はMicrosoft系ファイルに変換されてしまうことがわかりました。変換によって、ファイル形式の崩れやレイアウト崩れ、機能差分の発生が懸念されました。 この問題は、管理コンソールから利用できるBox Shuttleとは別の、Box Shuttleスタンドアローン版で対策できました。 Box Shuttleスタンドアローン版の検証をしたところ、Google系ファイルをGoogle系ファイルのまま移行できることがわかりました。Google系ファイルをGoogle系ファイルのまま移行できる専用の仕組みが用意されているとのことです。そこで、Box Shuttleスタンドアローン版を契約して移行することにしました。 Box Shuttleスタンドアローン版は単体契約がなく、Boxコンサルティング(Box社による移行支援)の契約内で提供されます。また、Boxコンサルティングが提供するのは、ベストプラクティスの説明やBox Shieldなど特殊機能の説明にとどまります。データ移行についても、スタンドアローン版の用意や使い方・仕様の説明が中心で、実際の移行設計までは行いません。 そのため、Box全体の設計は自分たちで担う必要がありました。 販社の提案どおりに、その導入支援でBox全体の設計支援を受け、安心感を得る選択肢もありました。しかし、販社の導入支援とBoxコンサルティングの両方を契約する予算は確保できそうにありませんでした。 そこで、次のように考え、導入支援を利用せずその分の予算をBoxコンサルティングに振り分けました。 Box全体の設計は複雑なものにするつもりがないので、Boxコンサルティングの範囲で十分 データ移行の難易度が極めて高いので、データ移行そのものにリソースを集中させるほうが移行の品質が上がる フォルダ構成の設計 現行の共有ドライブと同等の運用ルールを適用するため、Box管理者が集中管理するルートフォルダ「Topフォルダ」を用意しました。そして、共有ドライブと同名のTopフォルダにデータを移行する方針とし、移行後もGoogleドライブ時代の運用ルールとの対応関係がわかるようにしました。 ただ、外部共有に関するアクセス制限は、フォルダ自体の設定ではなく公開範囲を制御する「ラベル」を付与する方針としました。ラベルでの外部共有は、Box Shieldの「スマートアクセス」という機能を使って実現します。 Box Shieldとは、組織の情報の流れに対するリスクの軽減と保護に役立つ高度なセキュリティ製品です。( Box Shieldの概要 ) 「スマートアクセス」は、作成したラベルごとにアクセスポリシーを定義して適用することで、共有、外部コラボレータの追加、ダウンロードなどの操作を制御します。当社では、外部共有の範囲ごとにラベルを作成し、それぞれ以下のようなアクセスポリシーを定義しました。※海外法人向けに英語も掲載しています。 「秘密情報」ラベル:社外への共有不可 「関係者外秘」ラベル:個別指定した外部の方への共有が可能 「公開」ラベル:「リンクを知っている全員」の権限でリンクを発行可能(不特定多数へ公開できるようになる) ラベルはTopフォルダ単位で付与し配下ファイルに継承させる方針とし、現行の共有ドライブ単位で外部共有する方針と揃えることとしました。実際のTopフォルダの命名例は次のとおりです。 【Top】Box導入プロジェクト(ラベル:秘密情報) 【Top】ext-Box社連携フォルダ(ラベル:関係者外秘) 【Top】pub-Boxアカウント作成手順(ラベル:公開) 実際にBox上でTopフォルダを開くと、フォルダ名の右側にラベルが表示されます。 また、マイドライブの移行先として、「Personalフォルダ」も用意しました。マイドライブ上のデータの「オーナー」権限を参照し、オーナーのアカウントに対応する「Personalフォルダ」へ移行するよう設定しました。ただし、「Personalフォルダ」は、個人領域ゆえの外部共有の管理不全を防ぐため、一律「秘密情報」ラベルを付与し外部共有を行えない運用としました。 【Personal】taro.sato(ラベル:秘密情報) ラベルをTopフォルダ単位で付与する運用では、フォルダ単位のルールのままとなるため、外部共有の運用が利用者個人の判断に委ねられるという課題を解決できません。本来であれば、ファイル単位でその秘密区分に応じたラベルを貼り付け、個人情報が載ったファイルなど秘密レベルに応じて外部共有を制御する必要があります。また、共有ドライブでは外部共有を許可したものに接頭辞を付与していたので、Topフォルダにも同じ運用ルールを採用しました。そのため、ラベルと接頭辞で二重に管理している状態です。 目的が達成できない上に煩雑な運用となってしまっていますが、まずはBoxへの運用移行を最優先することとし、現場側・管理側双方の混乱を抑えるため、Googleドライブの運用に近いルールで設計しました。そして、将来的に秘密区分ラベルによる外部共有の制限を実現するため、手間ではありますが、利用者がラベルに慣れてもらえるようあえてこの運用を採用しました。 マイドライブのデータ整理方針 マイドライブ上のデータは個人管理下にあり、そのまま移行すると権限やデータ状況を組織として把握しづらい状態が続いてしまいます。さらに、マイドライブからのデータ移行では、次の2つの技術的な問題もありました。 1つ目は、継承したアクセス権限の問題です。Googleドライブには、親フォルダから継承したアクセス権限を子アイテム単位で削除できる仕様があります。一方、Boxのアクセス権限はウォーターフォール形式であり、親の権限が子へ必ず引き継がれます。このため、権限を漏らさずすべて移行しようとすると、Googleドライブ側で削除していたはずの継承権限がBox側で復活してしまいます。その結果、意図しない権限を付与してしまうリスクがありました。 2つ目は、フォルダの階層構造を維持したまま移行できない可能性があるという問題です。マイドライブには、作成者がオーナーになる仕様があります。実際の移行処理はオーナーを指定して実行するため、親フォルダと子フォルダでオーナーが異なる場合、それぞれ別のフォルダへ移行されてしまいます。その結果、元の階層構造を維持できない可能性がありました。 そこで、マイドライブの権限は移行しないこと、そして、移行対象のマイドライブ上のデータは一度共有ドライブに整理してもらう方針としました。ユーザーへの負担は増えてしまいますが、個人管理下にあるという問題を解消しつつ、アクセス権限をもれなく移行できる方法を選びました。 ただ、マイドライブから共有ドライブへのデータ移動は、これはこれで工数がかかります。ファイル単体であれば、移行先共有ドライブに投稿者権限があれば誰でも移動させられますが、フォルダごとはGoogle Workspaceの特権管理者権限を持っていなければできません。 そこで、共有ドライブにフォルダごと移動する特権管理者権限のみを切り出したカスタムロールを作成し、希望者に貸与することとしました。管理者権限に「共有ドライブへのファイルやフォルダの移動」という項目があるので、それだけを有効にしたものを作成し、セキュリティリスクを最大限抑えて管理者権限を提供しました。 それでも、移動しようとしたフォルダ内に外部オーナーのファイルが混じっていることがあり、スムーズに移動できないケースもありました。これについては、現場の皆さんに個別対応いただき、なんとか移動してもらいました。 また、マイドライブのデータは、アクセス権限や階層構造の問題はあるものの、データを移行できないわけではありません。そのため、次を条件に、マイドライブに置いたままBox(Personalフォルダ)に移行してよいこととしました。 意図しない権限付与を防ぐためアクセス権限は移行できないこと 階層構造が崩れてしまう可能性があること Box移行後に、Topフォルダに移動してもらうこと ユーザーのアクセス権限移行 アクセス権限を移行するには、移行元と移行先のマッピングが必要です。移行元の誰々のアクセス権限を移行先の誰々にどのアクセス権限で設定するか、という具合です。マッピングはBox Shuttleスタンドアローン版の機能で、ある程度自動で対応可能です。例えば、ユーザーであれば、デフォルトではメールアドレスを主キーとして移行元と移行先のマッピングができます。 問題は、アクセス権限自体のマッピングにあります。 GoogleドライブとBoxでは、アクセス権限の種類と中身が大きく異なります。そのため、Box Shuttleを利用して権限を移行すると、元の権限より強くなってしまうケースがあります。アクセス権限の移行対象について、以下に権限のマッピング表を掲載します。 Googleドライブの権限 Boxの権限 管理者 共同所有者 コンテンツ管理者 編集者 投稿者 編集者 閲覧のみ可(コメント可) 編集者 閲覧のみ可 ビューアー アクセス権限が強くなる組み合わせは、次の2つです。 管理者 → 共同所有者 閲覧のみ可(コメント可)→ 編集者 管理者 → 共同所有者 について 特に共同所有者は、所有者権限と並ぶ極めて強い権限です。所有者、共同所有者はフォルダの権限やラベルを変更できてしまうため、管理者の意図しない外部共有が発生してしまいます。TopフォルダをBox管理者で集中管理する方針としたのも、ユーザーに所有者、共同所有者を渡さないためです。 この問題は、共同所有者権限を編集者に変更するスクリプトを用意して対策しました。コラボレーションを変更するAPIがあるので、これを利用しました。 developer.box.com 変更対象の特定は、標準機能である「レポート」機能から「コラボレーション」の一覧を出力し、その中からスクリプトが「共同所有者」の権限を見つける方針にしました。レポートであればAPIを実行するより簡単でかつ実行速度が早いので、実行の手間は多少ありますが実装コストと実運用性に優れるので採用しました。 閲覧のみ可(コメント可) → 編集者 について こちらは、APIによる変更が行えなかったので、ユーザーに対策をお願いしました。共同管理者に変換された場合は、共同管理者となっている権限全てに対して変更処理を行うことができますが、編集者の場合、コンテンツ管理者と投稿者の置換先でもあり、正規のパターンも存在します。そのため、変更対象を特定できませんでした。 そこでユーザーには、閲覧のみ可(コメント可)が編集者になって困る場合、次のいずれかで対策してもらうこととしました。 Googleドライブ側の権限を「閲覧のみ可」にあらかじめ変更しておく 別途共有ドライブを作成するなど別領域を作成し、「閲覧のみ可(コメント可)」が必要なフォルダを切り出してもらう Box移行後に権限を修正してもらう 閲覧のみ可(コメント可)→ 編集者の変更は、本来編集できないメンバーが編集できるようになってしまうことが問題です。ただ、これは私個人の見立てですが、「閲覧のみ可(コメント可)」は資料の閲覧とダウンロードができる権限なので、「編集者」になったとしても情報漏洩リスクの上昇は限定的だと考えました。 ユーザーに依頼する対策には、ユーザーの工数負担に加えて品質の問題もあります。しかし、上記の仮説を踏まえ、多少の品質低下であれば受容できると考え、ユーザーに依頼する方針を採用しました。 グループのアクセス権限移行 グループのアクセス権限も、移行元と移行先のマッピングが必要です。デフォルトでは、同名のグループ間で権限のマッピングが行われます。 当社ではEntra IDでSCIM連携によるアカウントの自動プロビジョニングを行っています。そのため、ユーザーのマッピングに関する問題はほぼ発生しませんでした。一方、グループはユーザーからの利用申請で作成していることがほとんどのため、自動プロビジョニングによる対策を行えません。 そこで、Googleグループと同じ名前のBoxグループを作成するスクリプトを用意しました。 スクリプトは、Box社側で用意してあるサンプルを流用しました。 developer.box.com サンプルの時点で、.csvファイルを読み込む仕様のため大量処理もすぐに行えました。 前述の通り、Box Shuttleスタンドアローン版は、デフォルトでは同名グループをマッピング先として判定してくれます。そのため、Googleグループの一覧を「対象グループ」「対象グループの参加メンバーアドレス」で出力し、そのままサンプルスクリプトに読み込ませれば同名Boxグループを作成できます。これで簡単にグループのマッピングの問題を解決できました。 ただ、グループ権限の移行の課題はこれで対策できても、グループ権限の運用全体だとこれだけでは不足しています。 前提として、Boxへの移行は移行対象データが非常に多いことや各本部で業務の進め方が大きく異なるので、2024年12月から2025年12月までの間で各本部のタイミングで移行を実施する方針としました。移行期間を長く持ち、各本部に指定期間内で実施日を調整してもらったことで、結果的に最早で移行した本部と最遅で移行した本部とで移行時期に約6か月の差が発生しました。 この状態で問題になってくるのは、GoogleグループとBoxグループの同期タイミングです。GoogleドライブからBoxへ運用が移行すると、権限で利用するグループの運用もGoogleグループからBoxグループに切り替わります。 利用しなくなった側のグループ(Googleグループ)はメンバー更新をしなくなり、移行先のグループ(Boxグループ)のみ更新する運用になります。 すると、最初に移行した本部のGoogleグループとBoxグループは、最後の本部が移行するまでの6か月の間で大きなメンバー差異が発生します。 その状態で、最後の本部が移行するタイミングでグループの同期をかけると、古い情報でBoxグループのメンバーを上書きしてしまう恐れがありました。 この問題の対策は、グループの権限を「組織グループ」に置換してもらう方針としました。当社では、従業員・組織マスタと連携し、毎月の人事異動や組織変更に対して自動でメンバー変更をしたり、グループの作成・アーカイブを行ったりするグループを用意しています。それが、組織グループです。 この組織グループは、Entra ID上で作成され、Google Workspace、Boxにプロビジョニングされています。この仕組みにより、Google、Boxの双方の組織グループは常に最新状態のメンバーが維持されます。組織グループを利用すれば移行に伴うグループ間の同期の問題だけでなく、通常運用におけるグループの管理不全による過剰権限の問題も解消されます。 グループの権限移行に関しては、移行前のグループ間同期にプラスして組織グループへの置き換えで対応することとしました。 BoxとGoogleドライブの並行運用 Boxでは実現できない機能があるため、BoxとGoogleドライブで移行後も一部の業務を並行運用しています。メインはBox、Boxでは実現できない機能はGoogleドライブ、という具合で、運用ルールとしても明文化しています。 以下に、Boxでは対応できない主な機能をまとめます。 カテゴリ 詳細 Googleフォーム Box上でGoogleフォームを作成すること Box上のスプレッドシートにGoogleドライブ上で作成したGoogleフォームの回答結果を出力させること Apps Script(GAS) Box上でGASを作成すること Box上のGoogle系コンテンツをGASで操作すること スプレッドシート Box上のスプレッドシートでデータコネクタとマクロを利用すること(BigQueryやLooker Studioとの連携) GoogleスプレッドシートのIMPORTRANGE関数を利用すること Googleスプレッドシートの「保護されている範囲」を利用すること その他 Box上のコンテンツに対してGoogle APIを利用すること(ドキュメント、スプレッドシート、スライドのAPI) ほかにもBoxでは対応できない機能や、格納できないGoogleファイル形式があります。詳細は Box、Dropbox、EgnyteでGoogleドキュメント、スプレッドシート、スライドを使用する を参照してください。 データ移行においては、移行しないデータを別の共有ドライブに移動する方針とし、移行するものとしないもので仕分けることとしました。移行しないデータの共有ドライブを「Box移行不可共有ドライブ」と命名し、接頭辞に box_ng- をつけることでユーザー、移行担当者の双方で識別できるようにしました。 この件で特に問題だったのが、IMPORTRANGE関数です。IMPORTRANGE関数は別のスプレッドシートを参照するスプレッドシート独自の関数で、参照元、参照先の両方がGoogleドライブ上に格納されている必要があります。参照元はIMPORTRANGE関数を利用していて「IMPORTRANGE」で文言検索も引っかかるので、Box移行しなくて良いと判別できます。しかし、参照先はIMPORTRANGE関数で参照されていることを感知できません。そのため、Box移行してみたら実は他のシートからIMPORTRANGE関数で参照されていた、ということがよく発生しました。 これについては対策方法がなく、問題が発生したら参照先はIMPORTRANGE関数を利用していなくてもGoogleドライブに残してよいという方針にし、都度ユーザー側で対応いただくことにしました。 ユーザーとの調整について ここまで記載しましたが、「ユーザーの負担がかなり大きい」と思われたのではないでしょうか。実際、現場の皆さんには多大なご協力をいただいてBox移行を実現できました。やはり、Googleドライブは他のストレージサービスと比較し独自の仕様が多く、どうにもならないことが多数ありました。 その証左に、当社では一部の部署でDropboxを利用していましたが、これもBoxへ移行しています。DropboxからBoxへの移行も100TB超の非常に大規模なものでしたが、ほとんどトラブルが発生せずこれまで書いた制約もまったくなく移行できました。 また、当社では過去に大規模なシステム移行プロジェクトを実施した際、十分な体制を整えられず、現場からの評価が芳しくなかった経験もありました。 そういった状況なので、ユーザーとの調整は可能な限り高い品質が必要だと考え、次のことを意識しました。 トップダウンでBox移行に取り組んでもらうこと 会社、組織全体でBox移行のための工数を確保してもらえるよう強い納得感や安心感を与えること 前述の通りユーザーの負担が大きいと見込まれたので、一般メンバーレイヤーの片手間では対応できないと考えました。そうなると、管理職の皆さんに業務を調整してもらい、工数を開けてもらう必要があります。また、Boxへの移行は現場のみなさんに直接的なメリットがなく、過去の経験からすぐに信頼を得られる状況でもありませんでした。各組織、個人でそれぞれ業務や目標がある中でメリットのないBox移行に協力いただくには、役員/執行役員クラスの方からご理解を得る必要があると思いました。 上記を実現するために、以下の取り組みを行いました。 役員/執行役員が参加する定例会議でBoxについてのプレゼンを実施 管理職の皆さんへの説明会 特に管理職の皆さんへの説明会は、3日間設定した実施日のいずれかに参加してもらう方針とし、参加できない方には個別調整のうえオンラインで開催しました。ひと月で8回ほど実施しました。 説明会の録画は撮っていたので、参加できなくても問題ないようにはしていました。それでも、「グループのアクセス権限移行」でも記載した通り部署ごとに業務が大きく異なるので、質問も部署ごとに大きく異なることが予想されました。また、少しでも信頼を積み重ねるためにも可能な限り丁寧に説明する必要があると考え、ひと月で8回実施することになりました。 一般ユーザーへの周知は、当社が毎月実施している全体朝礼で実施しました。全体朝礼は、毎月の業績やトピックスの紹介などをオンラインで発表する場です。 コーポレートエンジニアリング部では、周知は基本的にSlack、Confluenceで行っていて、公衆の面前で何か発表する文化がありませんでした。それでも、Slackでの周知と比べると、実際に顔を出して発表するほうが、現場の皆さんに安心感を抱いてもらえると考えました。管理職の皆さんへの説明会と同様に少しでもZOZOの皆さんから信頼を得るために、前例のない中で、全体朝礼にて周知しました。 Box移行担当者のアサイン Box移行を現場側で指揮を取っていただくため、各組織にBox移行担当者をアサインしていただきました。よくSlackの導入などで耳にする「Slackチャンピオン」(社内でSlackを推進する選出されたメンバー)を、本プロジェクトでも採用する形です。 また、Box移行担当者は、以下の理由から「各組織のリーダークラスのメンバー」のアサインをお願いしました。 これまで記載した移行における制約事項が多く理解しづらい上、対応するだけでなく既存の業務フローの修正も必要であること 問い合わせ件数抑制のため、Box移行担当者に担当部署からの問い合わせの一次対応をしてもらうこと ただ、「各組織のリーダークラスのメンバー」ということは、普段から忙しい方にBox移行担当者を依頼することでもあります。そのため、Box移行担当者の負担が少しでも減るよう、可能な限りの対策を実施しました。 全社で共通のBox移行用Slackチャンネルを用意するのではなく、本部ごとにBox移行用Slackチャンネルを用意しました。 問い合わせをしなくても済むよう、Box移行担当者向けの資料は網羅性を意識して情報の過不足なく理路整然となるよう心がけました。 問い合わせ対応を業務の第一優先とし、業務時間中は30分以内に一次回答をすることを心がけました。 本部ごとにBox移行用Slackチャンネルを用意したことについて、全社で統一のものより本部ごとに分けることで周りの目を気にせず問い合わせしやすくなると考えました。他にも問い合わせの件数が非常に多くなると予想されたので、分けたほうが問い合わせは本部ごとのチャンネルに分散し、過去履歴も追いやすいと考えました。また、本部ごとに任意のタイミングで移行する方針としたので、本部ごとにチャンネルを用意したほうが移行の調整は行いやすいと判断しました。 資料作成については、網羅性を意識した結果、3万文字くらいのページになってしまいました。これだけの文字数になると当然読めない方も出てきますし、実際、後のアンケートで「難しかった」という意見も多くいただきました。 ただ、作成したのは「Box移行担当者向けの資料」で、一般メンバー向けの資料ではありません。短く簡潔に書けたほうがよいのはもちろんですが、書かれていないことは知りようがありません。書かれていないことで業務影響が出るくらいなら、網羅性を意識して情報の過不足ない資料のほうが全体的にメリットが大きいと考えました。 もちろん、資料の問題は別途対策を取りました。問い合わせ対応を業務の第一優先と記載した通り、問い合わせの品質を上げることでカバーする方針としました。1年間で約1100件の問い合わせをいただきましたが、90%の問い合わせで30分以内の一次回答をしました。 事前確認環境の用意 Box移行における制約やGoogleドライブとの並行運用など運用が複雑なので、それを口頭、文章の説明だけで理解するのは不可能だと考えました。そういうときは実際に触って動かしてみることが重要だと思い、「事前確認環境」と命名した環境を用意しました。 「事前確認環境」とは、レストランのプレオープン(お試し営業)のように、本番の運用が始まる前にBoxをお試し公開し、本番と同じように触れられるようにした状態のことです。別のテスト用の環境を用意したわけではなく、実際にこれから業務で使っていく環境をテスト用に公開したということです。 Box自体の業務利用は2024年12月から開始したのですが、その前の2024年10月頃にその時点でのGoogleドライブのデータを一旦移行させ、仮のBox移行後の環境を用意しました。実際の業務で利用しているデータをBoxに仮置きしたことで、普段の業務で利用している使い方をBox上でテストでき、より具体的な移行の制約とそれに伴う影響を確認できるようになりました。 しかし、事前確認環境も良いことばかりではありません。事前確認環境の問題点は以下の二点です。 移行容量が増えるので移行費用が増加してしまう 業務利用するBox上に、実際の運用を開始している領域と事前確認環境が混在してしまう 「移行容量が増えるので移行費用が増加してしまう」については、Box Shuttleに差分移行機能があるため、事前確認環境を構築したとしてもそこまで大きな費用増加はないと判断しました。差分は、基本的にはフォルダパスの差異を見ます。今回の移行では、マイドライブの整理などもお願いしましたが、データの7〜8割は共有ドライブにあったので、大きな差分は発生しないと考えました。 また、前述の通りデータ移行に費用を集中する方針だったため、多少の費用増加であれば許容範囲と判断しました。 「業務利用するBox上に、実際の運用を開始している領域と事前確認環境が混在してしまう」問題については、差分移行の仕様により本番移行時に業務利用しているデータを削除するリスクがありました。差分移行では移行元に存在しないデータは削除していたので、誤って事前確認環境に業務データを置くと本番移行時に削除されるリスクがありました。 この問題については、差分移行の仕組み上、接頭辞を付与するなど目視で確実に判別できる対策は難しいと判断しました。ただ、削除されてもBoxの標準機能で復元できるため、致命的な業務影響は避けられると考え、注意喚起を中心とした運用でカバーすることとしました。 移行の効果 ここまで、Box移行に関する様々な問題とそれに対する技術的、組織的な対策を講じてきました。その結果どうだったのかを皆さんにご覧いただきたいと思います。 プロジェクト全体について Box導入プロジェクトは2024年5月に開始し、外部共有の有無で段階を分けて移行を進めました。内部共有のみのデータは2025年5月に移行を完了させ、外部共有を含むデータは2025年12月に移行を完了させました。これにより、日本法人の移行は2025年12月に完了しています。海外法人についても移行を進めていて、2026年8月時点で残るは米国の子会社のみとなっています。 ただ、移行自体は順調に進められましたが、移行後に想定できなかった機能差異が見つかったり、AIに関する環境が劇的に変化したりするなど想定外の出来事もありました。そのため、アンケートで厳しい意見もありました。 Box移行に関するBox移行担当者アンケート 移行後3か月ほど経過した時点で、Box移行担当者を主なターゲットとしてアンケートを実施しました。以下にアンケートの結果を公開します。 説明会・資料のわかりやすさ 「わかりやすかった」「多少はわかりやすかった」の合計は54.4%でした。資料の網羅性や参照価値、説明会のわかりやすさを評価する声があった一方、次のような改善要望も寄せられました。 情報量が多く、要点をつかみにくい 時系列のToDoや役割分担がわかりにくい 専門用語が難しい 資料が分散していて、必要な情報を探しにくい 問い合わせ対応のわかりやすさ 「わかりやすかった」「多少はわかりやすかった」の合計は76.5%でした。返信の速さや丁寧さ、複数の対応パターンを示した回答を評価する声が多くありました。一方で、次のような改善点も挙がりました。 専門用語が多い 回答文が長い 本部専用チャンネルが非公開のため、ほかの本部から検索できない 自習に使えるナレッジを整備してほしい Boxへの移行が問題なくできたか 「問題なく移行できた」「多少トラブルはあったが移行できた」の合計は77.2%でした。事前確認環境や移行レポート、余裕を持ったスケジュール、迅速なサポート対応を評価する声がありました。一方で、IMPORTRANGEやBigQuery連携、動画・PDFの扱いなど、Google系ファイル特有の機能差異に戸惑う声も多くありました。このほか、次のような改善要望が寄せられました。 大規模な本部では取りまとめ体制を構築しにくい 全社への周知や、平易な言葉による説明が不足している セキュリティ上の制限が厳しく、使いにくい 目視で移行可否を判定する作業の負担が大きい BoxとGoogleドライブの体感利用率 全体の利用比率に換算すると、Boxが63.4%、Googleドライブが36.6%でした。 アンケートの総括 説明会や資料のアンケートでは、「わかりやすかった」「多少はわかりやすかった」を合わせた肯定的な意見が54.4%だったのに対し、問い合わせ対応は同様に合わせて76.5%と高スコアでした。計画通り、資料の網羅性を重視した結果生じたわかりにくさを問い合わせ対応でカバーできました。資料の網羅性についても、実際に好評でした。 一方で、アンケート回答者から専門用語の多さを指摘されました。これは、「共有ドライブ」や「マイドライブ」などといった、サービスの固有名詞に対する指摘と考えています。実際、問い合わせでは、「共有ドライブ」を「共有フォルダ」とおっしゃる方が多くいらっしゃいました。複数人に共有しているファイルやフォルダがまとまっているものは、すべて「共有フォルダ」という認識のようでした。 他にも、「共有ドライブ」や「マイドライブ」の違いがわからない方もいらっしゃいました。というよりも、格納先が「共有ドライブ」か「マイドライブ」なのかを全く意識していないようでした。 この問題はBox導入プロジェクトのスコープ外ですが、全社的なITツールに関する教育機会がまだ十分でないことも一因と考えています。 すぐに解決できる問題ではありませんが、関連部署も巻き込みながら、今後地道に取り組んでいきたいと思います。 また、本部ごとのチャンネル運用について、検索性が落ちるとの指摘もいただきました。当社では、複数の本部が密に連携して業務を進めているケースが多くあります。自本部だけでなく他本部の情報も把握する必要があるため、本部ごとのチャンネル分割が適していませんでした。 本部ごとのチャンネルでは、管理職の方とBox移行担当者の方のみを招待し、他本部の方を招待してよいことは明言していませんでした。本部間で密に業務連携しているケースを十分に想定できていなかったため、今後、似た施策をする場合はこの点を考慮しようと思います。 移行についてのアンケートでは、「問題なく移行できた」「多少トラブルはあったが移行できた」を合わせた肯定的な意見が77.2%ありました。利用率は「Box:63.4%、Googleドライブ:36.6%」でしたが、これはアンケートの選択肢が不足していたのが原因だと考えていて、私の体感ではBoxの利用率は70%を超えています。 いただいた指摘に「大規模本部での取りまとめ体制の難しさ」がありますが、この指摘は特に人数が多く配下の組織も細かい、一部の大規模な本部からいただきました。本部単位で組織が細分化されているうえ、組織間の調整も必要な部署ほど、取りまとめは難しくなる傾向にありました。 これは、「本部ごとのチャンネル」での反省と近く、私がZOZOの組織事情について十分熟知していなかったことが原因です。 全体としては、予想していた通りユーザーの負担は大きく、対策も想定通りの効果を得られない部分もありました。とはいえ、十分な結果を得られたと思います。 ただ、ユーザーからの問い合わせやアンケートを通じて、GoogleドライブとBoxの間には次のような機能差異があることもわかりました。 スプレッドシート間でシート移動不可 個別シートのURLが発行されない 編集画面のURLが非常に長くURLの共有が不便(SlackのURLの仕様アップデートで改善) CSVをブラウザで編集・フィルタ不可 Google編集画面の共有ボタンが動作を保証していない スプレッドシートの「セルへのリンク」で発行したURLにアクセスするとBoxログインを要求される ビューアー権限はファイルの中身のコピー不可(Excelだけは専用プレビューアプリの機能でコピー可能) 編集中にロックがかかるので、その間、移動・改名・履歴戻し不可 Box Edit(ローカルでの編集機能)はMicrosoft Officeとの同時編集に非対応 容量の大きいファイルや画像を大量に貼り付けたスプレッドシートで「保存エラー」が発生する この機能差異による業務影響は、私が想定しているより大きそうです。Boxの仕様上、改善できないこともありますが、運用方針の調整に加えBoxへの機能追加の要望や最新アップデートのチェックなどを通じて、積極的に改善していく計画です。 今後の改善案 アンケート結果のほか、昨今のAIに関する情勢やBoxのアップデートなども加味し、以下の改善計画を立てています。 AIに関するGoogleドライブ利用ケースの拡大 Box for Google Workspace V2の導入 Googleドライブの外部共有に関するルール及び制限設定の改善 AIに関するGoogleドライブ利用ケースの拡大 こちらはすでに実施済みの施策ですが、Googleドライブの利用シーンに「AI活用・自動処理の用途」を追加しました。「メインはBox、Boxでは実現できない機能はGoogleドライブ」と明文化していたルールの基本軸は変えず、「Boxでは実現できない機能」としてAIに関する機能を明確に追加した形です。使い分けは現場に一任しているところもあり、既存のルールの解釈次第でGoogleドライブのAI活用ができましたが、会社としてAI活用を推進していることもあり、あえて明文化することとしました。 Box for Google Workspaceの連携強化への期待 Boxには「Box統合」という機能があり、Boxからのデータをウェブ上の他のアプリケーションやサービスに接続する統合ソリューションを提供します。Box上でGoogle系ファイルを取り扱うには、「Box統合」で「Box for Google Workspace」というアプリケーションを有効にする必要があります。 Box社とのやり取りの中で、この「Box for Google Workspace」の連携が今後強化される見込みだと聞いています。これにより、前述の「移行の効果」で紹介した機能差異の一部は解消される見込みです。 導入によって見込まれる効果は大きいと考えているため、今後のアップデートを楽しみにしています。 Googleドライブの外部共有ルール・制限設定の改善 Box移行に伴いGoogleドライブでの外部共有を停止し、Box上でのみ外部共有を許可する運用ルールとしました。ただ、マイドライブについては、外部共有のルール整備がまだ完全には追いついていません。 共有ドライブを除外したうえでマイドライブからの外部共有を制限する設定自体はありますが、適用すると「他社から共有された共有ドライブ」への編集やファイル追加もできなくなってしまうことがわかりました。当社メンバーが管理者権限を持つ共有ドライブであっても同様です。 外部から共有ドライブを共有してもらうケースがあるため、業務影響は大きいと判断し、制限の適用は見送っています。マイドライブの外部共有ルールは、今後も引き続き検討していく予定です。 全体としては、バックアップを破壊する機能を持つランサムウェアが世に出回っている以上、バックアップとリストアの信頼性が極めて高いBoxをやめる選択肢は無いと思います。一方で、Google側のAI機能の拡充は当社に大きな利益をもたらすことも想像できるので、今後も情勢を見極めながらルールや運用方針を微修正していくことになります。 まとめ 本記事では、GoogleドライブからBoxへの大量データ移行と運用移行の取り組みを紹介しました。Box Shuttleスタンドアローン版の活用や、Topフォルダによるフォルダ構成の設計、権限移行時の考慮点を整理することで、ファイル形式やアクセス権限を崩さずに移行を実現できました。 また、Boxの導入・Googleドライブからの移行は完了しました。しかし、アンケートで見えた機能差異や未解決の外部共有ルールの課題を踏まえ、運用方針を継続的に見直しています。 GoogleドライブからBoxへの移行を検討している方がいれば、ぜひ参考にしてみてください。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、SRE部カート決済SREブロックの伊藤( @_itito_ )です。普段はZOZOTOWNのカート決済機能のリプレイス・運用・保守に携わっています。また、データベース(以下DB)領域でのテックリードを務めており、DBREとしてDB周りの運用・保守・構築に関わっています。 ZOZOTOWNでは以前からリプレイスを進めており、SQL Serverを中心としていたDB構成も、マイクロサービス化に伴ってAurora MySQLなどへ分割されてきました。 かつてはビジネスロジックの多くを、SQL Serverのストアドプロシージャ(以下、ストアド)が担っていました。データの近くで処理するため高速であり、ロジックをDBに集約できる利点があったためです。 一方で、「ストアドをスケールさせづらい」「ストアドのテストが書きづらい」といったような課題も抱えていました(参考: ZOZOTOWNリプレイス2020 )。 そのためリプレイスを進める中で多くのストアドが剥がされ、ロジックはアプリケーション側へ移ってきました。ただし、すべてを移し終えたわけではありません。残っているストアドもあり、改修は時折発生します。 そして、わずかな変更でも実行計画は変わります。結果として性能が大きく劣化する場合もあります。 本記事では、ストアドの変更をPull Requestの時点で検知する実行計画チェックCIを構築した取り組みを紹介します。GitHub ActionsとArgo Workflowsで本番DBへ一切接続せず推定の実行計画を取得し、Claude Code Actionによるレビューで性能懸念の有無を判定する構成としました。 目次 はじめに 目次 背景と課題 ストアド改修による性能劣化 従来のレビュー運用の限界 仕組みに求めた要件 CIの全体像 処理の流れ 各コンポーネントの実装 実行計画の取得 推定の実行計画を取得する CI用の一時名を付与する 実行計画のチェック ルールベース解析 変更前後の比較 LLMレビュー 判定をCIの成否へつなぐ マージをブロックする仕組み ワークフローの構成 dbre-review-passed ラベルによる救済 ラベルの抜け道をふさぐ 分析結果例 まとめと展望 背景と課題 ストアド改修による性能劣化 きっかけは、ストアドの改修リリース後に発生したDB負荷高騰でした。改修によってWHERE句で参照するカラムが変わり、それまで効いていたインデックスが使われなくなっていました。 問題が表面化したのは、リリースから時間が経ってからでした。通常のアクセス量であれば処理しきれていたためです。負荷の大きいイベントを迎えたところで、大量の読み込みによってDBが耐えられなくなりました。 ただし、より本質的な問題は別にありました。改修内容が数行の軽微なものだったため、リリース前後の性能検証が省略されていた点です。 対策として、リリース後の性能劣化をDBのパフォーマンスチェックやアラートで検知する仕組みも別途用意しました。ただし、リリース後の検知では影響が出てしまう可能性があります。理想はリリース前にも検知できることでした。 従来のレビュー運用の限界 ストアドの性能検証には、実行計画の確認が必要です。従来は、次のような観点での確認を開発者に委ねていました。 推定行数が極端に多い処理がないか テーブルのScanが発生していないか Index Seekでも絞り込める条件になっているか 実際の行数と予測の行数に大きな乖離がないか これらは実行計画を読める人でなければ判断できません。しかも、確認の実施やDBREへの相談も、開発者の判断次第でした。 つまり「改修が軽微かどうか」を、性能への影響を評価する前に人が判断していたのです。軽微な変更ほど検証は省略されやすくなります。この構造そのものが課題でした。 仕組みに求めた要件 再発防止策を検討する中で、仕組みに求める要件を4つ整理しました。 強制力があること … 人の判断で検証を省略できないよう、Pull Requestの単位で自動実行する 本番DBに影響を与えないこと … 検証のために本番DBへ接続したり負荷をかけたりしない 本番相当のデータが入った環境で確認できること … データがほとんどない開発用のDBでは、実行計画がその規模に合わせて最適化されてしまい、正しい情報が取れない 開発者の手間を増やさないこと … 既存のPull Requestフローに組み込み、追加の操作を求めない 要件2と要件3は、そのままでは両立しません。本番のデータ量に近い環境が必要ですが、本番DBは使えないためです。 そこでSTG環境を利用することとしました。ZOZOTOWNでは本番環境へのリリース前にSTG環境での動作確認を必須としており、STG環境へのリリースは stg ブランチへのマージで行う運用です。つまり、ストアドの改修は必ず stg ブランチ向けのPull Requestを通ります。 さらにSTG環境は、負荷試験の実行環境としても使っています。そのため本番規模のデータ量を保つようにしており、実行計画の確認先としても適していました。 stg ブランチ向けのPull Requestでストアドが変更されたときに、STG環境で推定の実行計画を自動取得して解析するCIを構築しました。開発者が必ず通る経路にチェックを置けるうえ、4つの要件をすべて満たせます。 CIの全体像 処理の流れ 構築したCIは、GitHub ActionsとArgo Workflows(Amazon EKS上で稼働)の2つで役割を分担しています。両者のデータの受け渡しはすべてAmazon S3を経由します。全体像は次の図のとおりです。 処理の流れは次のとおりです。 stg ブランチ向けのPull Requestが作成され、GitHub Actionsが起動する 追加・変更されたストアドの定義ファイルをすべて検出する ストアド名をCI用の一時名にリネームしたSQLファイルを作成する SQLと実行先のDB情報をまとめたJSONファイルをS3へアップロードする argo submit --wait でArgo Workflowsを起動する Argo WorkflowsがJSONを読み込み、ストアドごとに CREATE PROCEDURE → SHOWPLAN_XML で実行計画の取得 → DROP PROCEDURE を繰り返す 実行計画XMLと実行ステータスをS3へアップロードする GitHub Actionsが結果を収集し、ルールベース解析と変更前後の比較を実施する 解析結果をPull Requestへコメントする Claude Code Actionがレビュー結果と判定( block / pass )を追記する 手順4でDB情報を渡しているのは、対象となるDBが1つではないためです。ZOZOTOWNには複数のDBが存在し、ストアドの定義ファイルの配置場所に応じて実行先が変わります。そのため、どのストアドをどのDBで確認するかをJSONに含めてArgo Workflowsへ渡しています。 GitHub ActionsとArgo Workflowsで処理を分けたのは、ネットワーク構成が理由です。対象のDBはオンプレミス環境のプライベートなネットワーク内にあり、GitHub Actionsのランナーからは直接届きません。一方、既存のAmazon EKSクラスタからはDBへ到達できます。そこでDBへの接続はArgo Workflowsに任せ、GitHub Actionsは変更検出・解析・コメントだけを担う構成としました。 AWSへの接続には、CI/CDで一般的なOIDCによるIAM Roleの引き受けを利用しています。長期のアクセスキーを保持せずに接続でき、信頼ポリシーでは対象リポジトリのPull Requestイベントのみに絞っています。 両者の受け渡しにS3を選んだのは、実行計画XMLがサイズの大きなファイルになるためです。Argo WorkflowsのパラメータやGitHub Actionsのoutputで渡すには不向きでした。 各コンポーネントの実装 新たに用意したインフラリソースは、次の3種類です。 連携基盤 … GitHub Actions用のIAM Role(OIDC)、受け渡し用のS3バケット、Argo Workflows用のIRSAロール 実行計画の取得 … STG DBで実行計画を取得するArgo WorkflowTemplate LLMレビュー … モデル呼び出し用のApplication Inference Profile モデルの呼び出しにApplication Inference Profileを使っているのは、コスト配分タグを付与するためです。デフォルトの推論プロファイルでは、どのアプリケーションがコストを発生させたのかを追跡できません。 インフラのほかに、GitHub Actions側で動く処理をPythonのツールとして実装しました。責務ごとにモジュールを分割しています。 モジュール 役割 変更検出 git diff でPull Request内の変更されたストアド定義を抽出 定義パーサ ストアド定義の文字コードのデコード、CREATE名の置換 CI名の生成 CI用の一時名を生成(識別子128文字の上限に対応) ワークフロー実行 Argo Workflowsの呼び出しとS3経由の入出力 実行計画の解析 SHOWPLAN_XMLのルールベース解析 実行計画の比較 変更前後の実行計画の比較 コメント生成 Pull Requestコメント(Markdown)の生成 実行計画の取得 ここからは、処理の流れの手順6にあたる部分を説明します。STG環境のDBに一時的にストアドを作成し、推定の実行計画を取得する処理です。 実際にDBへストアドを作成するため、既存のストアドを壊さないための安全策も必要でした。取得の方法と、そのために重ねた安全策の順に説明します。 推定の実行計画を取得する 本CIが取得するのは、 SHOWPLAN_XML による 推定の実行計画 です。 SET SHOWPLAN_XML ON を有効にすると、以降のステートメントはコンパイルだけが行われ、実行計画が返ります。ストアドを EXEC しても、中のクエリは実行されません。 learn.microsoft.com 実行されないという性質には、もう1つ利点があります。 ストアドのパラメータを渡さなくても実行計画を取得できる 点です。 実際にストアドを実行する場合、必須のパラメータを省略すると「プロシージャまたは関数 'X' にはパラメータ '@p' が必要ですが、指定されていません」というエラーになります。しかし SET SHOWPLAN_XML ON の状態ではステートメントが実行されないため、この検査が働きません。そのため引数なしの EXEC だけで実行計画が返ります。 本CIはこの挙動を利用し、パラメータを一切渡していません。ストアドごとに引数を用意する必要がなく、CIとして自動化しやすくなります。 ただし精度は落ちます。パラメータの値が不明なため、オプティマイザは統計情報のヒストグラムではなく、密度ベクターによる平均値から行数を見積もります。データの偏りが大きい列では、本番の実行時と計画の形状が変わる場合もあります。 一方で、どのインデックスが使えるかという構造的な判断は、パラメータの値に左右されません。冒頭の事例のように参照するカラムが変わってインデックスが効かなくなるケースは、変更前後の実行計画を比べればスキャンの出現として現れます。精度が問題になるのは、値の偏りによって選ばれる計画が変わるような場合です。 取得の処理そのものは単純です。Argo WorkflowsからはsqlcmdをインストールしたPodを起動し、次のようなSQLを実行しています。 SET NOCOUNT ON ; GO SET SHOWPLAN_XML ON ; GO EXEC ${CI_PROCEDURE_NAME}; -- CI用にリネームして作成したストアド GO SET SHOWPLAN_XML OFF; GO CI用の一時名を付与する 作成するストアドには _CI_PR<Pull Request番号>_add|before|after_<ストアド名> という一時名を付与します。新規追加は add 、既存の変更は変更前が before 、変更後が after です。 _CI_PR で始まる名前は通常のストアドでは使わない形式のため、既存のストアドと衝突しません。 SQL Serverの識別子は最大128文字です。プレフィックスの付与で上限を超える場合は、元の名前を切り詰めてハッシュを付与し、一意性と可読性を両立させました。 def build_ci_name (proc_name: str , pr_number: int , variant: str ) -> str : suffix = SUFFIX_BY_VARIANT[variant] prefix = f "_CI_PR{pr_number}_{suffix}_" candidate = f "{prefix}{proc_name}" if len (candidate) <= MAX_IDENTIFIER_LEN: return candidate # 超過時: 元名を切り詰め + 8桁ハッシュで一意化 digest = hashlib.sha1(proc_name.encode( "utf-8" )).hexdigest()[: 8 ] budget = MAX_IDENTIFIER_LEN - len (prefix) - 1 - len (digest) if budget < 1 : return f "{prefix}{digest}" [:MAX_IDENTIFIER_LEN] return f "{prefix}{proc_name[:budget]}_{digest}" この関数が返すのはスキーマを含まない名前です。Argo Workflowsへ渡す際に [<スキーマ>].[<CI用の名前>] の形へ修飾しています。 さらにArgo Workflows側でも、渡されたSQLにCI用の名前が含まれているかを検証しています。万が一既存のストアド名でCREATEしようとした場合に、チェックを失敗させるためです。 # The caller supplies the fully-qualified CI procedure name and a # definition that already CREATEs under that name. Validate the form. if ! printf ' %s ' " $CI_PROCEDURE_NAME " | grep -qE ' ^\[[^]]+\]\.\[[^]]+\]$ '; then printf ' ERROR: ci_procedure_name must be in form [schema].[name], got: %s\n ' " $CI_PROCEDURE_NAME " | write_fail exit 0 fi # Sanity check: the definition must reference the CI name, so we never # accidentally CREATE under the original (production) procedure name. if ! grep -qF " $CI_PROCEDURE_NAME " /tmp/ci_def.sql ; then { printf ' ERROR: supplied definition does not reference %s; caller must rename to the CI name before submitting.\n ' " ${CI_PROCEDURE_NAME} " printf ' ---- supplied definition (head) ----\n ' head -c 2000 /tmp/ci_def.sql } | write_fail exit 0 fi GitHub Actions側で一時名に置換する処理が正しく動くことを前提にせず、DBに触れる直前でもう一度検証しています。 一時ストアドがSTG環境に残り続けないよう、 trap でエラー時にも必ず DROP PROCEDURE が実行されるようにしています。処理の冒頭でも同じ削除処理を呼び、前回の実行が異常終了して残っていた場合に備えました。 実行計画のチェック ここまでで実行計画XMLが手に入りました。続いて、処理の流れの手順8から手順10にあたる部分を説明します。 チェックは次の順に実行します。 ルールベース解析 … 決められた観点を機械的に抽出し、Pull Requestへ一覧をコメントする 変更前後の比較 … 変更によって悪化した点を抽出する LLMレビュー … 1と2の結果および実行計画XMLを読み、性能懸念の有無を判定する 最終的な判定を担うのはLLMレビューです。前段の2つは、LLMが実行計画を読み解くためのヒントを用意する簡易チェックという位置づけであり、検出結果そのものでマージをブロックしません。実行計画の解釈はLLMに委ねる方針としたため、ルールベース解析は簡易なものに留めています。 ヒントの用意をLLMに任せず、Python側で先に実行しているのには2つの理由があります。1つは、コストの増加率のような数値の算出や比較を機械的に処理することで、LLMへ渡す値が実行のたびに揺れるのを避けられるためです。もう1つは、LLMレビューより前にPull Requestへコメントまで済ませておくことで、LLMレビューが失敗した場合でも最低限のフィードバックが残るためです。 ルールベース解析 取得したSHOWPLAN_XMLを解析し、性能上の懸念を検出します。検出する観点は次の4つです。 観点 検出内容 Missing Index 推奨インデックスの未作成。Impactが50%以上なら high Scan / Lookup系 Table Scan / Clustered Index Scan / Index Scan / Key Lookup / RID Lookup 高コスト演算子 推定サブツリーコストが10以上の Sort / Hash Match / Nested Loops / Table Spool Warnings 暗黙の型変換、結合述語なし、tempdbへのスピル、統計情報のない列 たとえば暗黙の型変換(PlanAffectingConvert)は high として扱っています。冒頭の事例と同様に、インデックスが効かずスキャンへ変わる結果を招くためです。 変更前後の比較 既存ストアドの変更の場合は、変更前と変更後の両方の実行計画を取得して比較します。Pull Requestのベースブランチ( stg )上の定義と、Pull Requestの定義をそれぞれSTG環境に作成する形です。 悪化の判定基準は次の2つです。 総推定コストの増加率が20%以上 変更後にのみ出現した懸念(Missing Index / Warning / 高コスト演算子 / スキャン) 懸念の同一性は、ステートメント全文ではなくカテゴリとタイトルの組で判定しています。変更によってSQLの文言がわずかに変わっても、同じ懸念を「新規」と誤検知しないためです。 LLMレビュー ルールベース解析は決められた観点しか見られません。そこで、観点の外まで踏み込んだ判定をAmazon Bedrock経由のClaudeに任せています。実装には claude-code-action を使用しています。 github.com LLMには解析結果( findings.json )と実行計画XMLの両方を渡し、 XMLを一次ソースとして優先的に読ませる プロンプトとしました。ルールベースの抽出結果は照合用の参考情報として扱わせています。 - name : LLM review id : claude_review uses : anthropics/claude-code-action@a92e7c70a4da9793dc164451d829089dc057a464 # v1.0.159 with : github_token : ${{ secrets.GITHUB_TOKEN }} use_bedrock : "true" use_sticky_comment : "true" settings : | { "env" : { "ANTHROPIC_DEFAULT_SONNET_MODEL" : "${{ secrets.BEDROCK_CLAUDE_SONNET_INFERENCE_PROFILE_ARN }}" } } claude_args : | --max-turns 20 --model sonnet --json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["block","pass"]},"review":{"type":"string"}},"required":["verdict","review"]}' prompt : | このPRで変更されたストアドプロシージャの推定実行計画チェック結果を、性能観点でレビューしてください。 参照ファイル(${{ env.OUTPUT_DIR }}/ 配下): - findings.json … ストアドごとのルールベース解析結果(Missing Index / Scan / 高コスト演算子 / 警告 / before-after比較) - plans/<proc>.after.xml , plans/<proc>.before.xml … 取得した SHOWPLAN_XML(存在する場合) やること : 1. まず findings.json を読み、各ストアドの status / change_type / proc_name を把握する。 2. `review` フィールドの冒頭に、対象ストアドの一覧を以下の形式で出力する(findings.json の内容をもとに作成): - 各ストアドの proc_name / change_type(追加 or 変更)/ status(実行計画取得済み / スキップ / 取得失敗) 3. status=analyzed のストアドについて、plans/ ディレクトリを確認し、<proc>.after.xml(および modified の場合は <proc>.before.xml)が存在すれば、 **XML を一次ソースとして優先的に読み込み**、以下の観点で直接分析する: - オペレータ構成(Seek vs Scan、Hash/Merge/Nested Loops の選択、Sort の有無) - EstimatedTotalSubtreeCost の配分と高コストノード - MissingIndex 要素の Impact 値とカバー列 - Warnings(PlanAffectingConvert / NoJoinPredicate / SpillToTempDb) - before→after で計画形状・コストが悪化していないか(コスト値も明記する) findings.json の findings はルールベースの抽出結果として参照し、XML から読み取れる追加の懸念と照合する。 4. status=plan_failed(Argo ワークフローでの実行計画取得に失敗したもの)は findings.json の notes にエラー詳細が含まれる。 エラー内容(構文エラー・テーブル不在・権限不足 等)を読み取り、原因と対処法を開発者向けに日本語で説明する。 5. status=skipped(実行計画を取得できなかったもの)は XML が存在しないため、変更されたストアド定義の CREATE 文を読み、 SELECT * / NOLOCK の濫用 / 暗黙の型変換を招く比較 / インデックス非対応のWHERE / カーソル使用 等の静的な懸念があれば指摘する。 6. 改善提案(インデックス追加、述語の見直し等)を簡潔にまとめ、日本語でレビュー内容を Markdown 形式で作成し `review` フィールドに設定する。 7. 以下の基準で判定し、結果("block" または "pass" )を `verdict` フィールドに設定する。 - block : 変更後に severity=high の新規発生・悪化がある、または重大な静的懸念がある - pass : それ以外(status=plan_failed のみの場合も pass とする) 注意 : - これは推定実行計画に基づく参考情報。パラメータを渡さずに取得しているため、本番と計画形状が異なりうる旨を `review` の冒頭(対象一覧の直後)に記載する。 - 懸念が無ければ「重大な性能懸念は検出されませんでした」と簡潔に述べる。 判定をCIの成否へつなぐ 先ほどのステップ定義では、 claude_args に --json-schema を指定していました。このオプションを渡すと、最終的な出力が指定したJSON Schemaに沿う形へ強制されます。今回は verdict ( block / pass )と review (レビュー本文)の2つを必須項目として定義しました。 結果は steps.<id>.outputs.structured_output から取得できます。次のように verdict を読み取り、 block であればジョブを失敗させています。 - name : Check LLM verdict if : vars.SP_PLAN_CHECK_LLM_VERDICT_ENABLED == 'true' env : STRUCTURED_OUTPUT : ${{ steps.claude_review.outputs.structured_output }} run : | VERDICT=$(echo "$STRUCTURED_OUTPUT" | jq -r '.verdict // "pass"' ) echo "LLM verdict: ${VERDICT}" if [ "$VERDICT" = "block" ] ; then echo "::error::LLMレビューにより重大な性能懸念が検出されました。レビューコメントを確認してください。" exit 1 fi 構造化出力を強制しているため、自然言語の応答をパースする必要がありません。LLMの判定をそのままCIの成否へ接続できます。 なお、判定によるブロックはリポジトリ変数 SP_PLAN_CHECK_LLM_VERDICT_ENABLED で切り替えられるようにしました。テスト運用の段階では無効にしておき、誤検知の傾向を確認してから有効化する想定です。 マージをブロックする仕組み ここからは、チェック結果をマージの可否へ接続する仕組みを説明します。ただし 現時点では、この章で説明する設定をまだ有効にしていません 。テスト運用として、結果をPull Requestへコメントするだけの状態で誤検知の傾向を見ています。 ワークフローの構成 ここまで説明したチェックは、1本のGitHub Actionsワークフローで実行しています。 pre-check / plan-check / llm-review の3ジョブで構成しています。 stg ブランチのブランチ保護ルールで plan-check と llm-review を必須ステータスチェックに指定すると、チェックNG時にマージがブロックされます。 加えて、このワークフローを補助する目的で簡単なワークフローを2本追加しています。どちらも後述する dbre-review-passed ラベルの扱いを制御するものです。 dbre-review-passed ラベルによる救済 推定の実行計画によるチェックは万能ではありません。NGと判定されたが実際には問題ないケースや、CI自体が失敗するケースもあります。 そこで、DBREが結果を確認して問題ないと判断した場合に dbre-review-passed ラベルを付与すると、マージできる仕組みにしました。ラベルが付与されている場合は pre-check ジョブが後続をスキップします。 - name : Check dbre-review-passed label id : check_label run : | LABELS=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/labels" --jq '[.[].name] | @json' ) if echo "$LABELS" | jq -e 'contains(["dbre-review-passed"])' > /dev/ null ; then echo "dbre-review-passed ラベルが付与されているため、後続チェックをスキップして成功終了します。" echo "skip=true" >> "$GITHUB_OUTPUT" else echo "skip=false" >> "$GITHUB_OUTPUT" fi GitHubの仕様上、ジョブレベルのスキップはSuccess扱いになります。そのため必須ステータスチェックを設定したままでも、ラベルによる救済フローが機能します。 ラベルの抜け道をふさぐ ラベルによる救済は、そのままでは抜け道になります。開発者が自分でラベルを付与すればチェックを回避できてしまいます。 ここでGitHubの仕様が制約になりました。 特定のラベルについて、付与できるユーザーを制限する仕組みが存在しません。 ラベルの付与はTriage以上のロールに許可された操作であり、ラベルの種類によって権限を分けることはできません。 docs.github.com そこで、付与を防ぐのではなく、付与された後に取り消す方針としました。ラベルの付与をトリガーとしてワークフローが起動し、条件を満たさない場合は自動で削除します。 ラベル付与後に新しいコミットがpushされた場合、ラベルを自動削除して再チェックを強制する DBREではないユーザー(特定の権限がないユーザー)がラベルを付与した場合、ラベルを自動削除する 分析結果例 以下が実装完了後の分析結果例です。Pull Requestのコメントとして出力されます。 各ストアドの分析結果が羅列された後、最後にマージしても問題ないかの判定が表示されます。 上図は問題なかった時の例です。NGの場合は次のようにCIがエラーになります。 前述のブランチ保護ルールを設定していれば、マージがブロックされます。 まとめと展望 本記事では、ストアドの改修による性能劣化をPull Requestの時点で検知するCIを構築した取り組みを紹介しました。 GitHub ActionsとArgo Workflowsで役割を分担し、S3を介して連携することで、本番DBへ接続せず推定の実行計画を自動取得する構成としています。取得した実行計画はルールベース解析で整理し、その結果と実行計画XMLをLLMレビューが読んで判定する形にしました。 現在はテスト運用の段階であり、マージをブロックする設定はまだ有効化していません。誤検知の傾向を見ながら、ルールベース解析の閾値とLLMレビューのプロンプトの判定条件を調整し、順次適用を進めていく予定です。 一方で、この仕組みでカバーできない観点もあります。取得しているのは推定の実行計画であり、クエリを実際に実行していないため、従来のレビューで確認していた「実際の行数と推定行数の乖離」は判断できません。統計情報の陳腐化に起因するような劣化は、引き続きリリース後のパフォーマンスチェックで捉える必要があります。PR時点のチェックとリリース後の監視は、どちらかで置き換えられるものではなく、互いを補完するものだと捉えています。 今回のターゲットはストアドのみで、アプリケーション側で組み立てるクエリは対象外です。ストアドに絞ったのは、負荷高騰の原因がストアドの改修だったことに加え、定義ファイルから変更内容を特定しやすいためです。ただし、アプリケーションから直接実行されるクエリも、変更差分から抽出して実行計画を取得することは理論上可能です。同じ仕組みを広げていければ、DBへの変更全体がチェックの対象になります。そうした形を目指して、対象範囲を広げていきたいと考えています。 「人が検証の必要性を判断する」運用は、判断が正しくなければ機能しません。判断そのものを仕組みに委ねることで、はじめて再発防止と呼べる状態になると考えています。DBの信頼性を高めるための取り組みを、引き続き進めていきます。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、WEAR開発部バックエンドブロックの小山です。普段は弊社サービスである WEAR のバックエンド開発を担当しています。 WEARの投稿検索(フリーワード検索)では、 OpenSearch を検索基盤として利用しています。従来の検索方法では、ユーザーが入力するクエリの表記ゆれや誤字・略称により、適切な検索結果を返せないケースがありました。この課題を解決するため、全文検索とベクトル検索を組み合わせたハイブリッド検索を導入しました。しかし、導入の過程ではストレージの肥大化やレイテンシの増加、検索精度のチューニングといった技術的課題に直面しました。本記事では、これらの課題と解決に至るまでの取り組みをご紹介します。 なお、ハイブリッド検索の導入にあたって行った検索インデクサーの刷新(ベクトルデータのOpenSearchへの連携基盤)については、以下の記事で紹介しています。本記事では、検索処理を担うアプリケーション側で直面した課題と解決策に焦点を当てます。 techblog.zozo.com 目次 はじめに 目次 背景・課題 従来のフリーワード検索の限界 ハイブリッド検索とは hybrid queryの構造 normalization-processorによるスコアの統合 ベクトル検索の導入で直面した課題 1. ストレージの肥大化 2. レイテンシの問題 3. 検索精度の課題 課題を解決したアプローチ 1. ベクトルデータの量子化と_source最適化によるストレージ削減 スカラー量子化(fp16)の適用 derived_sourceによる_sourceからのベクトル除外 施策の効果 2. warmup APIの定期実行による暖気機構の導入 スパイクの原因はコールドスタート warmup APIを実行するだけでは解決しなかった 導入した暖気機構 3. スコアの組み合わせ比率の最適化 search pipelineの設定 全文検索の重みを大きくした理由 精度検証の進め方 スコア閾値による関連性の低い結果の除外 あいまい検索を実行する条件の制御 効果 今後の改善 まとめ 背景・課題 従来のフリーワード検索の限界 WEARの投稿検索では、ユーザーが入力したクエリをルールベースでDBのレコードと突き合わせ、性別・カラー・カテゴリー・ブランド・タグなどの検索条件に変換して検索結果を返しています。しかし、この方式だけでは以下のようなケースで適切な検索結果を返せませんでした。 表記ゆれ:「Tシャツ」「Tシャツ」「ティーシャツ」のように同じ意味でも表記が異なるクエリ 誤字・略称:ルールベースのマッピングではカバーしきれない入力 意味的な類似検索:クエリと字面は異なるが意味的に近い投稿をヒットさせられない たとえば「平成レトロ」で検索した場合、従来は「#平成レトロ」というタグが付いた投稿しかヒットしませんでした。「夏フェス 野外」のような複数キーワードの検索は、両方のタグに完全一致する投稿しか返せず、ほとんどヒットしない状態でした。 これらのケースをカバーするには、タグの完全一致とは別の検索手段が必要でした。一般的に、キーワードが厳密に一致しない検索には次のような手法があります。 全文検索:タイトルや説明文などのテキストを対象に、キーワードと字面が一致する投稿を広く拾える ベクトル検索:キーワードの字面ではなく「意味」の近さで検索でき、別表記や言い換えにも対応できる WEARの課題は、「夏フェス 野外」のように字面の部分一致で解決できるケースと、「ティーシャツ」のように字面が異なるケースの両方にまたがっています。そのため、どちらか一方ではなく両者を組み合わせるハイブリッド検索の導入を決定しました。 なお、ハイブリッド検索は従来の検索を置き換えるものではありません。ルールベースのマッピングには、クエリの意図を性別やカラーなどの構造化された検索条件へ正確に落とし込めるという強みがあります。そのため従来のマッピングの機構は残したまま、タグ検索のヒット件数が少ない場合に検索結果の量を補助する位置づけでハイブリッド検索を適用しています(適用条件の詳細は後述します)。WEARでは、このハイブリッド検索を利用した検索機能を「あいまい検索」と呼んでいます。 ハイブリッド検索とは ハイブリッド検索とは、キーワードマッチによる全文検索と、意味的な類似度で検索するベクトル検索を組み合わせ、双方のスコアを統合して検索結果を返す検索手法のことです。OpenSearchでは、バージョン2.10で導入された hybrid query と normalization-processor を利用することで実現できます。 それぞれの検索手法には以下のような長所・短所があり、互いに弱点を補完し合う関係にあります。 検索手法 長所 短所 全文検索(BM25) キーワードの完全一致に強い、高速 同義語・言い換えに弱い、意味を理解しない ベクトル検索(k-NN) 意味的な類似性を捉えられる、表記ゆれ・言い換えに強い 計算コストが高い、キーワードの完全一致に弱い hybrid queryの構造 ハイブリッド検索のクエリは、hybrid queryの中に全文検索クエリとベクトル検索クエリを並べた構造になります。WEARでは、全文検索にはmulti_matchクエリを、ベクトル検索には検索キーワードをOpenSearch側でベクトル化して検索するneuralクエリを利用しています。 { " query ": { " hybrid ": { " queries ": [ { " multi_match ": { " query ": " 夏フェス 野外 ", " fields ": [ " title.sudachi ", " content.sudachi ", " ... " ] } } , { " neural ": { " text_embedding ": { " query_text ": " 夏フェス 野外 ", " model_id ": " <モデルID> " } } } ] } } } なお、フィールド名の .sudachi は形態素解析器(Analyzer)を表しています。全文検索における日本語の形態素解析にはSudachiを利用しており、kuromojiで解析したフィールドも併用できる構成にしています。 normalization-processorによるスコアの統合 ここで問題になるのが、2つの検索のスコアを単純に比較できないことです。BM25のスコアには上限がなく、マッチした単語数や出現頻度によって数十以上の値も取る一方、ベクトル検索の類似度スコアは0〜2程度の狭い範囲に収まります。そのまま足し合わせると、値の大きい全文検索のスコアに結果が引きずられてしまいます。 normalization-processorは、このようにスケールの異なるスコアを揃えて統合するための仕組みです。search pipelineに設定すると、検索結果へ次の2段階の処理を適用します。 正規化(normalization):各クエリのスコアを同じ土俵で比較できるように変換する。min_max方式では (スコア - 最小値) / (最大値 - 最小値) で0〜1の範囲に正規化する 結合(combination):正規化したスコアを重み付きで結合し、最終スコアを算出する なお、WEARの検索では、ハイブリッド検索のスコアを並び順に使っていません。スコアは、関連性の低い投稿を除外する足切りに使っています。足切りを通過した投稿は、人気順スコアで並べ替えて返します。関連度だけで並べると、検索結果に含まれる投稿の質がばらつきやすいためです。関連度は足切りで担保し、その上で人気の高い投稿から表示して検索結果の質を保つ設計です。 検索リクエストからレスポンスまでの流れは以下のとおりです。 ベクトル検索の導入で直面した課題 ハイブリッド検索を実現するためにベクトル検索を導入したところ、リリース前に解消すべき以下の技術的課題が発生しました。 1. ストレージの肥大化 投稿ごとに1024次元の埋め込みベクトルを保持するため、インデックスのストレージサイズが大幅に増加しました。ベクトルの各要素を32ビットの浮動小数点(fp32、4バイト)で保持すると、1投稿あたり約4KB(1024次元 × 4バイト)のベクトルデータが追加されます。対象の投稿は膨大なため、全量を投入するとベクトルデータだけで数十GB規模の追加になる見積もりでした。実際に、一部の投稿データで構築した検証用インデックスを実測すると、fp32のベクトルを含むサイズは13.2GBに達していました。 ベクトルデータの増加は、ストレージコストだけの問題ではありません。後述のとおり、k-NN検索ではHNSWグラフを検索時にメモリへロードしておく必要があるため、ベクトルデータが増えるほど必要なメモリ容量も増えます。ディスクは増設で対応できますが、メモリの確保はインスタンスの増強が必要になり、コストへの影響が大きくなります。 2. レイテンシの問題 ベクトル検索(k-NN検索)では、近いベクトル同士をつないだグラフ構造(HNSW)をインデックスとして構築し、検索時にそれをたどることで高速な近傍探索を実現しています。このグラフをメモリにロードしていない状態で検索すると、レイテンシが大きく悪化する問題を抱えていました。実際に、通常時は200ms程度で応答する検索が、不定期に20秒程度まで悪化するスパイクを起こしていました。 3. 検索精度の課題 ハイブリッド検索を導入しただけでは期待する検索精度が得られず、全文検索とベクトル検索のスコアをどのような比率で組み合わせるかのチューニングが必要でした。 たとえば、ベクトル検索の重みを大きくしすぎると、ブランド名で検索した際に意味的に近い別ブランドの投稿が混入してしまいます。逆に全文検索の重みを大きくしすぎると、表記ゆれや言い換えをカバーするというベクトル検索導入の狙いが薄れてしまいます。 課題を解決したアプローチ 1. ベクトルデータの量子化と_source最適化によるストレージ削減 ストレージの肥大化に対しては、ベクトルデータの量子化(fp16化)と、 _source フィールドからのベクトルデータ除外(derived_source)の2つの施策で対応しました。 スカラー量子化(fp16)の適用 量子化手法にはFaissエンジンのスカラー量子化(SQfp16)を採用しました。ベクトルの各要素をfp32から16ビットの浮動小数点(fp16)に変換して保持する手法で、ベクトルデータのサイズを50%削減できます。プロダクト量子化やバイナリ量子化ほどの圧縮率はないものの、精度への影響が小さく、事前学習も不要でマッピングの encoder 設定を追加するだけで導入できます。精度と導入の手軽さのバランスを重視して、この手法を選びました。 knn_vector フィールドの定義は以下のとおりです。 { " text_embedding ": { " type ": " knn_vector ", " dimension ": 1024 , " method ": { " name ": " hnsw ", " space_type ": " innerproduct ", " engine ": " faiss ", " parameters ": { " ef_construction ": 128 , " m ": 16 , " encoder ": { " name ": " sq ", " parameters ": { " type ": " fp16 " } } } } } } ただし、fp16化のみを適用した場合、インデックスサイズは13.2GBから12.2GBへと7.6%の削減にとどまりました。k-NN検索用のデータ構造は半減しても、 _source フィールドに元のベクトル値がそのまま保存されているためです。 derived_sourceによる_sourceからのベクトル除外 そこで、OpenSearch 2.19で実験的機能(experimental)として導入された knn.derived_source を有効化しました。この設定を有効にすると、ベクトルデータが _source に重複して保存されなくなり、必要な場合はk-NN検索用のデータ構造から導出されるようになります。 { " settings ": { " index ": { " knn ": true , " knn.derived_source.enabled ": true } } } なお、derived_sourceの有効化で検索時に20ms程度のオーバーヘッドを確認しましたが、許容範囲と判断しています。 施策の効果 2つの施策によるストレージ削減効果は以下のとおりです(検証用インデックスで実測)。 インデックス設定 ストレージサイズ 削減率 ベースライン(fp32) 13.2GB - fp16のみ 12.2GB 7.6% fp16 + derived_source 5.2GB 60.6% fp16化により検索精度への影響が懸念されたため、fp32のインデックスとfp16のインデックスのそれぞれに同一クエリで検索をかけ、結果を比較して検証しました。上位20件の重複率はおおむね95%以上で、スコアの差も小数点以下6桁目程度の違い(例:1.5960883と1.5960877)にとどまりました。結果が異なる投稿は実際に目視で確認し、検索クエリの意図から外れていないことを確かめた上で採用しています。 なお、この施策は投稿数がもっとも多いコーディネートのインデックスのみに導入しています。データ量の少ないインデックスでは削減効果が限定的なためです。 2. warmup APIの定期実行による暖気機構の導入 レイテンシの問題に対しては、OpenSearchの warmup API を定期実行する暖気機構を導入しました。 スパイクの原因はコールドスタート まず、k-NN統計API( GET /_plugins/_knn/stats )でレイテンシ悪化時の状態を分析しました。すると、メモリ不足によるグラフ破棄(Eviction)は発生しておらず、キャッシュミス(Miss Count)が多発していることがわかりました。つまり、スパイクの原因はメモリ不足ではなく、以下のメカニズムによるコールドスタートでした。 インデックス更新に伴いバックグラウンドでmerge/refreshが発生し、新しいHNSWグラフが作成される 新しいグラフはまだネイティブメモリにロードされていない そこに検索リクエストが到達すると、ディスクからのグラフロードが発生し、完了まで数秒〜数十秒待たされる warmup APIを実行するだけでは解決しなかった コールドスタート対策として、グラフを事前にメモリへロードするwarmup APIをデータ投入後に実行しましたが、それでもスパイクは解消しませんでした。調査の結果、原因はデータ投入とwarmupのタイミングにありました。 処理 同期/非同期 Bulk APIによるデータ投入 同期(ただし検索可能になるのはrefresh後) HNSWグラフ構築 非同期(Bulk完了後もバックグラウンドで継続) flush/merge 非同期(グラフデータのディスク永続化) warmup APIの対象になるのは、flush/mergeが完了しディスクに永続化済みのグラフのみです。HNSWグラフ構築とflush/mergeはBulk APIの完了後も非同期で続くため、データ投入直後にwarmupを実行しても、永続化前のグラフには適用されません。つまり「Bulk APIの完了 ≠ warmup実行可能なタイミング」だったのです。 導入した暖気機構 この調査を踏まえ、インデックスの洗い替え時と日常の差分更新のそれぞれに対して暖気を組み込みました。 フィールドの追加やマッピングの変更の際は、新しいインデックスを作成してデータを入れ直す洗い替え(リインデックス)が必要になります。洗い替えの際は以下の順に処理し、暖気が完了したインデックスだけがユーザーからの検索を受けるようにしています。 新インデックスを作成し、データを投入する refreshを実行し、投入したデータを検索可能にする warmup APIを実行し、HNSWグラフをネイティブメモリにロードする エイリアスを新インデックスに切り替える 一方、日常運用では差分更新によって新しいHNSWグラフが継続的に作られるため、洗い替え時の暖気だけではカバーできません。WEARでは、差分データを10分間隔で投入するワークフローが動いています。このワークフローの最終ステップにwarmup APIの実行を組み込み、差分投入で作られた新しいグラフを毎回暖気しています。ワークフローの流れは以下のとおりです。 差分データを抽出する(BigQuery) データをGCSからS3へ転送する ベクトル化してOpenSearchへ投入する(詳細は前述のインデクサー刷新の記事を参照) 投入完了を確認する 削除済みの投稿をインデックスから除去する refreshを実行し、投入したデータを検索可能にする warmup APIを実行し、新しいHNSWグラフをネイティブメモリにロードする なお、暖気を組み込んでもスパイクを完全には防げません。そのため、あいまい検索専用のOpenSearchクライアントに0.5秒の短いタイムアウトを設定しています。タイムアウトや一時的なエラーが発生した場合は、従来のタグ検索の結果を返すフォールバックを実装しています。あいまい検索が失敗してもユーザーには正常なレスポンスが返る多層的な構えとし、フォールバックの発生は監視基盤に記録して検知できるようにしています。 3. スコアの組み合わせ比率の最適化 検索精度の課題に対しては、ハイブリッド検索の実行結果を確認しながら、全文検索とベクトル検索のスコアを組み合わせる際の最適な重みを探りました。 search pipelineの設定 スコアの正規化と結合は、以下のsearch pipelineで行っています。正規化手法にはmin_maxを、結合手法には重み付き算術平均(arithmetic_mean)を採用し、最終的に全文検索を重視した重みに落ち着きました。 PUT /_search/ pipeline / hybrid - search - pipeline { " description ": " Post processor for hybrid search ", " phase_results_processors ": [ { " normalization-processor ": { " normalization ": { " technique ": " min_max " } , " combination ": { " technique ": " arithmetic_mean ", " parameters ": { " weights ": [ <全文検索の重み>, <ベクトル検索の重み> ] } } } } ] } 全文検索の重みを大きくした理由 検索方式 重み 理由 全文検索 大 ユーザーが入力したキーワードへの一致を重視。ファッション検索ではブランド名・アイテム名などの正確な一致が重要なため ベクトル検索 小 セマンティックな関連性を補完。「秋コーデ」に対する「紅葉」「ニット」のような関連概念の発見を支援するため 前述のとおり、ベクトル検索の重みを大きくしすぎるとキーワードと一致しない投稿が混入します。ファッションの検索ではブランド名やアイテム名の正確な一致が重要なため、キーワード一致を主、意味的な類似を従と位置づけてこの比率を選びました。 精度検証の進め方 精度検証は、nDCGのような定量指標による自動評価ではなく、目視による定性評価で進めました。評価用のクエリを用意し、検索結果をスプレッドシートへ並べて、キーワードとの関連性や表示順の違和感をチームで確認していきました。 調整は以下の手順で進めました。 ベクトル検索単体の足切りスコア(min_score)を見極める 全文検索の単体での足切りスコアを決める 重みづけを設定した上でハイブリッド検索を試し、ハイブリッド検索としての足切りスコアを決める その結果を定性評価する 実際に調整を進める中で、ベクトル検索・全文検索の単体では良い結果が得られているのに、ハイブリッド検索として組み合わせると期待どおりの結果にならないケースがありました。このようなケースを1件ずつ確認しながら、重みは固定したまま、足切りスコアの調整で精度を追い込んでいきました。 スコア閾値による関連性の低い結果の除外 重みの調整とあわせて、全文検索・ベクトル検索のそれぞれにスコアの下限値(min_score)を設定しています。ベクトル検索はどんなクエリに対しても「もっとも近い」ドキュメントを返してしまうため、閾値を設けないと関連性の低い投稿が検索結果に含まれてしまいます。閾値は、複数の評価用クエリで検索結果を定性評価し、クエリごとに求めた「関連性の低い結果を除外できるスコア」の平均値として決定しました。 なお、スコアの分布は固定ではない点に注意が必要です。実際、リリース後にOpenSearchをバージョンアップした際、全文検索のスコアが全体的に低く出るようになり、正常な検索結果が閾値で除外される事象が発生しました。この事象は、検索結果からの遷移率などのKPIモニタリングがきっかけで検知しました。その際は、リリース判定時と同じ検索語句でスコア順TOP1000件の分布を確認し、閾値を引き下げて対応しました。min_scoreを利用する場合は、バージョンアップなどによるスコア分布の変化まで含めた運用を想定しておくことをおすすめします。 あいまい検索を実行する条件の制御 背景で述べたとおり、あいまい検索は従来の検索を補助する位置づけであり、すべてのキーワードに対して実行しているわけではありません。検索キーワードがタグにマッピングされ、タグ検索で一定の件数以上の結果を得られる場合は、タグ検索の結果をそのまま返します。タグに完全一致する検索結果は精度が高く、十分な件数があればあいまい検索で補完する必要がないためです。 効果 これらの取り組みにより、ハイブリッド検索を本番運用できる状態でリリースできました。 ストレージ:fp16量子化とderived_sourceにより、インデックスサイズを13.2GBから5.2GBへ約60%削減しました レイテンシ:暖気機構により、コールドスタートに起因する最大20秒のレイテンシスパイクを大幅に抑制しました。リリース時のあいまい検索のレイテンシはp99平均369msで、事前の負荷試験と同水準の結果でした 検索精度:スコアの重みと閾値の調整により、キーワード一致を保ちつつ、表記ゆれや言い換えを含むクエリでも意図した投稿がヒットするようになりました たとえば「平成レトロ」と検索すると、従来はタグ「#平成レトロ」が付いた投稿しかヒットしませんでした。ハイブリッド検索の導入後は「平成」「平成ギャル」をタグや説明文に含む投稿も返せています。「秋っぽいコーデ」のようなあいまいな言い回しのクエリや「夏フェス 野外」のような複数キーワードのクエリでも、意味の近い投稿をヒットさせられています。 今後の改善 ハイブリッド検索の導入で表記ゆれや言い換えへの対応は進みましたが、運用する中で新たな課題も見えてきています。 部分一致によるノイズ:検索キーワード「白シャツ」に対して、「白Tシャツ」や「黒シャツ」を含む投稿まで高い類似度でヒットしてしまう 複数キーワードの検索意図の解釈:「ロングスカート 低身長」のような検索では、どちらか一方しか含まない投稿もヒットしてしまい、クエリ全体の意図を汲み取れない こうしたノイズを除外するチューニングとあわせて、コーディネート画像の活用も検討しています。社内ではすでに別部署が 画像からの特徴量の抽出・テキスト化 に取り組んでいます。その成果を検索データとして取り込む案と、画像そのものをベクトル化する案の両方が選択肢です。テキスト情報だけでは表現しきれないコーディネートの特徴から検索できるようになれば、あいまい検索の適用範囲をさらに広げられると考えています。 また、全文検索側では辞書の拡充も検討しています。語彙数の多い辞書や独自のユーザー辞書を導入すると、「夏フェス」のような複合語やトレンドから生まれる新語も1語として扱えるようになります。意図しない単語分割が減り、全文検索のノイズの低減にもつながると考えています。 さらに、あいまい検索の手前にあるクエリ理解の改善も検討しています。キーワードから検索条件へのマッピングの精度を高めれば、あいまい検索に頼る前の段階で、より適切な検索結果を返せるようになります。 まとめ 本記事では、WEARのフリーワード検索にハイブリッド検索を導入した際の課題と、その解決のための技術的工夫をご紹介しました。 ベクトル検索導入によるストレージ肥大化には、fp16へのスカラー量子化とderived_sourceによる _source 最適化で対応しました k-NN検索のレイテンシ問題には、リインデックス時の暖気完了後のエイリアス切り替えと、差分更新ワークフローへのwarmup組み込みによる暖気機構で対応しました 検索精度の課題には、全文検索とベクトル検索のスコアを組み合わせる重みとスコア閾値の最適化で対応しました OpenSearchでのハイブリッド検索の運用を検討している方や、精度検証の進め方に悩んでいる方の参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、ZOZOTOWN開発1部iOSブロックのぎゅです( @kyuriza62 )。ZOZOに入社して3年目で、普段はZOZOTOWN iOSアプリの新機能の開発や、Storyboard/XIB形式の既存画面をSwiftへ置き換える作業などを担当しています。これまでもリファクタリングは担当してきましたが、影響範囲の大きい画面を扱った経験はありませんでした。 ZOZOTOWNのiOSアプリには、アプリ内のさまざまな導線からアクセスされる「カート追加画面」があります。今回は、このカート追加画面の実装を、ZOZOTOWN iOSチームで運用しているMVVM + Repositoryアーキテクチャへ段階的に移行するリファクタリングに取り組みました。本記事では、その過程で考えたこと、実際に手を動かして分かったこと、レビューを通じて学んだことをまとめます。 本記事で紹介するカート追加画面のリファクタリングは、ZOZOTOWN iOSチーム全体で取り組んでいるアーキテクチャ刷新事例の1つです。チームとしての取り組みや知識共有の仕組みについては、 ZOZOTOWNのiOSアーキテクチャの進化とチームの変化 にもまとめています。本記事と合わせて読んでいただくと、個々の取り組みとチーム全体の文脈を立体的に理解いただけます。 目次 はじめに 目次 今回のリファクタリング対象となる画面 カート追加画面とは なぜリファクタリングしようと思ったのか 既存コードの課題 課題1. 表示の流れを追いにくく可読性が低い 課題2. テストが書けない仕組みになっていた リファクタリングの進め方 今回のリファクタリングのスコープ 新機能案件との兼ね合い 不具合のリスク分散 意識したこと ViewModelのインタフェース(Input / Output)を先に決める 結合する前にテストを整える 新規実装を追加しやすい設計にする PRを小さく分割してレビューしやすくする 最終的に目指す形を見据えて進める 今後のリファクタリングのステップ リファクタリングのまとめ 前後で変わったこと 表示状態をViewControllerが抱える形をやめた 「更新を呼ぶ」から「状態を渡す」へ テストできる範囲が広がった 学び 既存の実装を担保しつつ、リファクタリングをいかに行うのか 正しく使うことを前提にした設計を避ける メソッド名と中身を一致させる 設計の根拠を言語化する 新アーキテクチャ導入時は既存パターンの安全性やデータの流れる順序を確認する PRのコメント機能を活用する おわりに 今回のリファクタリング対象となる画面 カート追加画面とは カート追加画面は、商品のサイズや色ごとの在庫を表示し、お気に入りの登録・解除やカートへの追加ができる画面です。商品詳細画面・検索結果画面・ランキング画面・お気に入り画面など、ZOZOTOWNアプリ内の非常に多くの画面から呼び出されます。 呼び出し元が多岐にわたるということは、この画面に何か問題が起きたときの影響範囲が非常に大きいということでもあります。実装を変更する際は、機能追加のしやすさだけでなく、既存のユーザー体験を壊さないことへの配慮が普段以上に求められる画面です。 なぜリファクタリングしようと思ったのか カート追加画面の実装は長らく手が入っておらず、チームで運用しているアーキテクチャ方針に則っていない状態でした。アーキテクチャ方針に則っていないコードは、レビューする側や実装する側の認知コストを高め、新機能の追加や既存機能の改善のハードルを上げてしまいます。リファクタリングの必要性は以前から認識されていましたが、案件の納期やリソース不足などの事情から、当該画面のコードベースを刷新せずに古い実装のまま踏襲する判断が続いていました。 そうした中、カート追加画面に関連する新機能案件が立ち上がりました。このまま新機能を追加すると認知コストの高いコードがさらに増えるため、この機会に合わせて複雑な画面のリファクタリングに挑戦したいと思い、カート追加画面のリファクタリングへ着手することにしました。 既存コードの課題 ZOZOTOWN iOSチームで運用しているMVVM + Repositoryアーキテクチャでは、ViewModelが画面に表示する状態の組み立てを担います。また、Repositoryでは、APIとの通信やレスポンスの変換などデータ取得に関する処理を担います。このように責務を分けることで、ViewModelは具体的なデータの取得方法を意識せずに済み、ロジックだけを取り出してテストできる状態を目指しています。 しかしカート追加画面を担っていた CartModalStockViewController は、いわゆるFat ViewControllerの状態になっていました。表示ロジックだけでなく、本来ViewModelやRepositoryに分離されるべきデータ加工やビジネスロジックまでViewControllerに直接書き込まれていました。表示に必要な状態はViewController自身がプロパティとして持ち、状態が変わるたびに画面の更新を明示的に呼び出す構造でした。また、コールバッククロージャを介したデータの受け渡しが複数の階層にまたがっており、複雑性の高いコードベースになっていました。 この状態が引き起こしていた課題のうち、リファクタリングを進めるうえで特に大きかったのは以下の2つでした。 課題1. 表示の流れを追いにくく可読性が低い 1つのクラスが表示・状態管理・ビジネスロジックを同時に抱えると、そのクラスを変更する理由が役割の数だけ生まれます。単一責任の原則から外れた状態のため、在庫の取得方法を変えたいだけでも、表示の組み立てや画面の更新まで同じクラスの中で影響を追うことになります。 この構造では、ある表示が最終的にどこで決まっているのかを追いにくくなります。表示状態が複数の箇所から書き換えられるため、1つを修正するたびに、他の表示への影響も毎回確認する必要がありました。 課題2. テストが書けない仕組みになっていた ビジネスロジックが UIKit 依存のViewControllerに書かれていたため、ロジックだけを取り出してテストできませんでした。テストを書くには、まずViewControllerを生成し、画面が表示される状態まで用意しなければなりません。そのうえでテストしたい処理にたどり着くには、表示中の状態も作り込む必要があり、準備だけで大きな手間がかかりました。 結果として、ビジネスロジックに関するテストが1つも存在していませんでした。つまり、変更後も正しく動いていることを保証する手段がなく、毎回手作業で確かめる必要がありました。 どちらの課題も、ViewModelを新たに用意して責務を分割することで解消を目指しました。課題の把握ができたので、次にリファクタリングをどこまでの範囲で、どのような計画で行っていくかを整理しました。 リファクタリングの進め方 今回のリファクタリングのスコープ リファクタリングの計画段階では、カート追加画面の全体をスコープとすることも検討していました。しかし、以下の2つの理由からリファクタリングのスコープをロジックの受け皿となるViewModelと、そこへ状態を流す仕組みを整えるところまでに絞る方針としました。 新機能案件との兼ね合い 実装着手前に調べたところ、この画面のコードは約7年にわたって大きな手が入っておらず、全体をリファクタリングするには最低でも3か月以上かかると分かりました。一方で、並行していた新機能の案件は、それ自体の難易度が高く、納期も決まっていました。アニメーション関連の実装、新しい画面の追加、新たに扱うデータ群への対応など、新機能の案件だけを見積もった時点で既にタスクが多く積み上がっている状態でした。 画面全部のリファクタリングを先に終わらせてから新機能に着手する案も検討しましたが、他部署と連携して進める新機能の納期を優先する判断としました。また、新機能もViewModelを用意してから実装した方がクリーンに書けるため、その土台となる最低限の範囲を先に整え、新機能と並行して進めることにしました。 不具合のリスク分散 範囲を絞ったもう1つの理由は、不具合が起きたときのリスク分散です。新機能とリファクタリングを同時にリリースすると、万が一不具合が発生した際に、新機能側の実装問題なのかリファクタリング側の実装問題なのか、原因の切り分けが難しくなります。データ取得をRepositoryへ切り出すところまで一度に進めると、変更範囲が新機能の実装と重なります。そのため、まず安全に移行できる範囲から確実に積み上げ、残りは新機能のリリース後、段階的に進める方針を取りました。 意識したこと ViewModelのインタフェース(Input / Output)を先に決める 既存のロジックを移し始めると、「この処理はViewModelに持っていくべきか、ViewControllerに残すべきか」で毎回判断に迷います。たとえば在庫を取得したあとにセクションを組み立てる処理は、どちらに置くべきか決めにくい部類でした。 そこで設計を始める前に、この画面でどんなデータが出入りするのかを整理しました。ViewModelへのInput(ユーザー操作やライフサイクルイベント)とOutput(画面表示のための状態)を、型として明示しています。実際の宣言は、InputとOutputをそれぞれプロトコルとして定義した次のような形になります。 protocol CartModalStockViewModelInput { /// 在庫情報を取得したときに呼ぶ func didFetchStock (items : [ StockItem ] ) } protocol CartModalStockViewModelOutput { /// 画面に表示するセクションの配列 var sectionsPublisher : AnyPublisher <[ StockSection ], Never > { get } } 先にInput / Outputを決めておくと、この判断が「Inputとして受け取るか、Outputに影響するか」という問いに置き換わります。最初に挙げた例なら、在庫の取得はInput、組み立てたセクションはOutputなので、組み立て自体はViewModelの役割だと判断できました。判断の基準が先にあることで、移す先に迷うことが減りました。 機能を足すときも同じで、新しいInputを増やすのか、既存のOutputに影響するのかという形で検討を進められます。 また、テストが書きやすくなるという利点もありました。検証する内容がInputを流してOutputを確かめる形に定まるため、何をテストすべきか迷わずに済みます。 結合する前にテストを整える ViewControllerとつなぐ前に、ViewModel単体のテストを書きました。決めておいたInputを流し込み、期待するOutputが得られるかを検証する形です。ViewModelは UIKit に依存していないため、ViewControllerと結合してシミュレータ上で画面を操作しなくても、テストだけでロジックの正しさを担保できます。ロジックとテストを先に揃えたことで、結合の段階では既存の挙動が変わっていないかだけを見ればよくなりました。 新規実装を追加しやすい設計にする この画面へ新しい機能を足すときに、チームで運用しているアーキテクチャに沿ったまま実装でき、誰が読んでも意図の分かるコードにしておきたいと考えました。独自の書き方を減らし、OS標準の仕組みへ寄せる方針で進めました。ViewControllerに残っている処理についても、同じパターンに揃えています。 画面の更新は、手動で更新メソッドを呼ぶ形から、 Combine の @Published で状態変化に連動する形へ変えました。表示する項目を新しく追加しても、値を更新したあとに画面へ反映させる呼び出しを書き足す必要がないためです。 テーブルの表示は、 UITableViewDataSource から UITableViewDiffableDataSource へ置き換えました。行の種類が増えたときも、スナップショットの組み立てを変えるだけで、差分の適用とアニメーションが自動的に行われるためです。 PRを小さく分割してレビューしやすくする 既存のViewControllerとの結合は、PRを小さく分割し、1つずつ挙動を確認しながら進めました。ViewModel側の正しさはテストで担保済みなので、レビューで見るべきは「つなぎ方が正しいか」「既存の見た目や挙動が変わっていないか」だけになります。実際、複雑な変更については動作を録画した動画をPRに添えて説明したところ、単なる確認で終わらず、より良い設計についての議論に発展したこともありました。 最終的に目指す形を見据えて進める スコープを絞って進めるとしても、最終的にどんなアーキテクチャを目指すのかは着手前に整理しておきました。ゴールが見えていれば、今回作るものが全体のどこに収まるのかが分かった状態で実装できます。最終的に目指すアーキテクチャは下図のとおりです。 今後のリファクタリングのステップ 今後の工程は、下図の7つのステップです。 カート追加画面にもともと存在していた在庫情報の取得・お気に入りの登録解除・カートへの追加といった中心的なビジネスロジックは、残りの7つのステップで順次移していく計画です。大きな流れとしては、ドメインごとにRepositoryを作り、テストを整えてから、ViewControllerのロジックをViewModelへ移していくという順序です。 工程を分けるうえで意識したのは、ロジックの土台となるデータ層を先に整えてから、ViewControllerとViewModelのつなぎ込み作業に移ることです。足場が揃ってからつなぎ込みに入れば、不具合が出たときに新しい層の問題か、つなぎ方の問題かを切り分けられます。 ドメインの順番は、依存関係の根元から決めました。お気に入りもカートへの追加も在庫の行の中にある操作なので、在庫のデータの形が決まらないと残り2つのインタフェースも決まりません。 リファクタリングのまとめ 前後で変わったこと リファクタリングの前後で、データの流れは下図のように変わりました。 リファクタリングする前は、ViewControllerが UITableViewDataSource へ更新を委譲し、再描画も呼び出す形でした。移行後は、ViewModelが配信する状態をViewControllerが受け取り、 UITableViewDiffableDataSource へ反映します。 表示状態の管理をViewModelへ移したことで、課題1に挙げた可読性の低さも、課題2に挙げたテストが書けない状態も解消しました。ここまでの取り組みを通して、大きく変わったことは以下の3点です。 表示状態をViewControllerが抱える形をやめた 移行前は、画面表示に必要な状態をViewController自身がプロパティとして保持していました。移行後は、ViewModelが配信する状態を受け取るだけになっています。 表示状態を書き換える箇所がViewModelに集まったため、ある表示がどこで決まるのかを追いやすくなりました。1つの表示を直すときに、他の表示への影響を毎回確認する必要もなくなりました。 「更新を呼ぶ」から「状態を渡す」へ もう1つ大きいのが、画面更新の考え方が変わったことです。移行前は、データを変えたあとにViewControllerから更新用のメソッドを呼ぶ必要がありました。つまり、呼び忘れれば画面は古いまま、順番を間違えれば意図しないタイミングで再描画される、という構造です。 移行後は、状態が変わってから表示に反映されるまでの流れをあらかじめ実装しておく形にしました。以降は状態を変えるだけで画面へ反映され、テーブルの更新もスナップショットを渡すだけになりました。 差分の計算やアニメーションの制御を自前で書く必要がなくなり、「更新処理を呼び忘れる」という不具合の種そのものが構造的になくなりました。 テストできる範囲が広がった 移行前は、この画面にViewModelのテストが1つもありませんでした。移行後は、画面の状態管理をViewModelが担うため、Inputを与えてOutputを検証するテストが書けるようになりました。 まだViewControllerに残っている処理はあります。一方、今回移した範囲では、「たぶん動く」ではなく、テスト結果を根拠として変更できるようになりました。 今回のリファクタリング範囲はすでにリリース済みで、大きな不具合の報告もなく安定して稼働しています。 学び ユーザー影響の大きい画面を慎重にリファクタリングする中で、特に自分の糧になった6つの学びを紹介します。 既存の実装を担保しつつ、リファクタリングをいかに行うのか 今回の大きな収穫は、既存のビジネスロジックを先に新しいViewModelへ実装し、そこにテストを用意してからViewControllerと結合したことです。テストが先にあると、結合で挙動が変わってもすぐに気づけます。 既存コードを読むだけでは、仕様として意図した挙動と、たまたま今そうなっている挙動を見分けられません。テストを書く過程で、その2つを1つずつ明文化できました。 先にテストを整備しておけば、結合の段階では既存の見た目や挙動が変わっていないかだけに集中できます。ユーザー影響の大きい画面ほど、この順番で進める効果が大きいと実感しました。 正しく使うことを前提にした設計を避ける 実装当初は、ViewControllerから画面の更新メソッドを呼び出す箇所が残っていました。レビューでは、呼び出し忘れが起きる設計になっている点を指摘されました。実装者が手順を守ることを前提にした設計は、守られなければそのまま不具合につながります。今回は状態に連動して自動的に更新される形へ見直し、呼び出し忘れという不具合の起き方そのものをなくしました。 メソッド名と中身を一致させる あるメソッドの内部でフィルタ処理を行っていた実装について、メソッド名からは中で値が絞り込まれていることが読み取れない、とレビューで指摘を受けました。1つのメソッドが名前から想像できない処理まで抱えていると、何がどの順番で起きるのかを呼び出し側から追えません。そこでフィルタ処理を呼び出し側へ移し、メソッドの責務を名前どおりの範囲に絞りました。隠れた処理が減るほど、変更時に確認すべき範囲も小さくなります。 設計の根拠を言語化する レビューで「なぜこの仕組みを採用したのか」と問われた際、明確な根拠を持って選んだものではなかったことに気づきました。結果的にその仕組みは不要と判断して削除しましたが、この経験から、技術選択の理由を都度言語化する習慣の大切さを学びました。 新アーキテクチャ導入時は既存パターンの安全性やデータの流れる順序を確認する UITableViewDiffableDataSource へ移行したあとも、以前の仕組みを前提にしたコードが一部に残っていました。新しい仕組みではデータが画面に反映されるまでの順序が変わるため、古い前提のまま動くコードは意図しない値を扱う可能性があります。実際に複数の箇所で同様の指摘を受けました。新しい仕組みを導入するときは、データがどの順番で流れて表示されるのかを正確に把握したうえで、既存コードがその前提と矛盾していないかを一つひとつ見直す必要があると学びました。 PRのコメント機能を活用する ある変更について「既存の挙動と変更なし」という一言のコメントだけを添えていたところ、レビュアーから詳細を聞かれました。実際の挙動を動画付きで説明したところ、単なる確認で終わらず、より良い設計についての議論に発展しました。複雑なロジックほど、テキストだけでなく動画などを活用して説明することの効果を実感しました。 これら6つの学びを振り返ると、レビューで気づかされたものには共通点がありました。どれも自分の中では分かっていたことが、コードからは読み取れない状態になっていた、というものです。 そこで今後は、PRを出す前に、初めて読む人の視点で自分のコードを読み直すことを意識しています。メソッド名から中の処理が想像できるか、複数の実装方法がある中でその仕組みを選んだ理由を説明できるかなどの観点を持っておくことで、レビューを待たずに自分で気づける範囲は広がると考えています。 おわりに 本記事では、ZOZOTOWNのカート追加画面を段階的にMVVM + Repositoryアーキテクチャへ移行するリファクタリングの取り組みを紹介しました。 ViewModelとテストを新たに整備したことで、これまでテストが存在しなかった処理の挙動をテストで担保できるようになりました。大規模なリファクタリングの経験が少なく、Fat ViewControllerを前に迷っている方にとって、本記事が何かしらのヒントになれば幸いです。 今後は引き続き、リファクタリングを完成に向けて進めていきたいと考えています。 設計の相談やレビューを通じて多くの気づきをくださったチームメンバーに、この場を借りて感謝いたします。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、MA部MAシステム開発ブロックのまんさくです。普段はマーケティングオートメーションシステムの開発・運用を担当しています。 ZOZOTOWNでは、アプリのPush通知やLINE、メールでキャンペーンを配信しています。MA部は、これらの配信を担うマーケティングオートメーション(MA)システムを開発しています。 配信には大きく2種類あります。特定の会員セグメントへ一括で送る「バッチ配信」と、会員一人ひとりの行動に合わせて送る「パーソナライズ配信」です。本記事で扱うのは後者です。その配信可否を決める前段階のジョブについて、DB・ジョブ起動基盤・メッセージングの3つの層で改善に取り組みました。うまくいった2つの改善と、検証の結果見送った1つの改善を、判断の根拠とあわせて紹介します。 次のような方に読んでいただければと思います。 PostgreSQLで更新の多いテーブルのbloatやパフォーマンス劣化に悩んでいる方 Cloud Tasksを使ったシャード分割処理の構成と流量制御を検討している方 Pub/Subのordering keyとスケールの関係、特にカーディナリティを上げる際のトレードオフを知りたい方 目次 はじめに 目次 背景・課題 1. 過剰な書き込みを削減する 何が起きていたか なぜ深刻だったか autovacuumの設定と限界 対策1:書き込みを減らす 対策2:インデックスを小さくする ここまでの効果と残る課題 対策3:収集の起点を小さいテーブルへ移す 2. Cloud SchedulerジョブをCloud Tasksへ移行する 最初はシンプルなScheduler増加を選んだ Cloud Tasksへの集約 タイムアウトと運用上の注意 3. Pub/Sub ordering keyのカーディナリティを上げる試み なぜordering keyを使っているか 高カーディナリティ化の狙い 受信側はスケールしたが、送信側が重い 見送りの判断と残る選択肢 まとめ 背景・課題 パーソナライズ配信では、会員ごとの配信候補を収集する処理と、候補を最適化して配信判断へ渡す処理が連携して動きます。ここでの最適化は、会員ごとに配信する時間帯を調整したり、Push通知・LINE・メールのどのチャネルで届けるかを選んだりする処理です。基盤の全体像とリプレイスの経緯は、次の記事の「リプレイス後のアーキテクチャ」で紹介しています。 techblog.zozo.com この基盤はGoで実装し、Google Cloud上で動かしています。DBはAlloyDB、ジョブの起動にはCloud Scheduler、処理間の連携にはPub/Subを使っています。 負荷を分散するため、会員IDを100で割った余りによって100グループに分け、それを2グループずつまとめた50シャード単位で毎分処理しています。 この構成で運用を続けるなかで、3つの層それぞれに課題が表面化しました。本記事で扱う内容を先にまとめます。 層 課題 対策 結果 DB(AlloyDB) 実際には配信しない候補まで書き込み・更新を繰り返し、テーブルとインデックスが肥大化 書き込み回数の削減とインデックス再設計 採用 ジョブ起動(Cloud Scheduler) 50個のジョブがシャード範囲を直書きしており、流量調整も監視も煩雑 Cloud Tasksへ集約 採用 メッセージング(Pub/Sub) ordering keyがシャードと結びつき、下流の並列度がシャード数で頭打ち ordering keyを会員ID単位へ細分化 見送り 以降では、それぞれの対策を紹介します。 1. 過剰な書き込みを削減する 1つ目は、配信候補テーブルへの過剰な書き込みの削減です。 何が起きていたか ここまで配信候補テーブルと呼んできたものは、AlloyDB(PostgreSQL互換)上の offer_candidates テーブルです。最適化の過程で扱う候補を1行ずつ持ち、実際には配信しない判定で終わる行も多いため、行数が膨らみます。処理状態や配信時刻に加えてパーソナライズ判定用の項目も持つため、1行あたりのサイズも大きめです。 問題の中心は、候補の配信時刻を付け直す処理でした。同一の会員・チャネルに紐づく複数行へ、同じ内容のUPDATEを繰り返し発行していました。本来まとめられるはずの更新が、処理のたびにばらばらと走っていました。書き込みが必要以上に増え、テーブルとインデックスのbloat、HOT更新の効きにくさ、VACUUMの遅延が重なっていました。 ここでいうbloatとは、削除・更新によって使われなくなった領域がファイル内に残り、実データの量に対してテーブルやインデックスのサイズが膨らんだ状態を指します。PostgreSQLでは更新のたびに古い行が残るため、回収が追いつかないと領域が積み上がっていきます。 なぜ深刻だったか PostgreSQLではUPDATEは実質的に「新しい行バージョンの追加」です。古いバージョンはVACUUMが回収するまで残ります。更新列がインデックスに含まれるとHOT(Heap-Only Tuple)更新も効きにくく、インデックス側にもエントリが増えます。大きなインデックスが複数ある状態では、1回のUPDATEのコストも、その後の回収コストも両方上がります。 回収をさらに難しくしていたのが、リードレプリカ参照です。ここで効いてくるのが hot_standby_feedback というパラメータです。onにすると、レプリカは「いま自分が読んでいる最も古い行」をプライマリへ伝え、プライマリはその行を回収せずに残します。レプリカ上のクエリが途中で消えた行を参照して失敗する事態を防ぐ仕組みです。 このパラメータをoffにはできません。offにすると、プライマリのVACUUMとレプリカ上の長いクエリが衝突し、キャンセルが多発して処理が進まなくなるためです。onのままにすると、レプリカで長いクエリが走っているあいだプライマリのVACUUMが死んだ行を回収しにくくなります。 どこまで回収できるかはxmin horizonという基準で決まります。実行中のトランザクションが見ている可能性のある行は、まだ回収できないためです。この基準を押し戻しているセッションを1日分記録してみると、最も多く現れていたのはレプリカ側の処理ではなく、収集処理そのものでした。2分を超えるトランザクションがシャードごとに並走し、そのあいだは死んだ行を回収できません。つまり書き込みを減らすことは、生成されるデッドタプルの量を減らすだけでなく、回収を妨げる時間そのものを縮めることでもありました。 bloatの影響は読み取りにも及びます。 EXPLAIN にIndex Only Scanと出ていても、Heap Fetches(ヒープへの参照)が多ければインデックスだけでは完結せず、本体ページを大量に読みます。実効コストはSeq Scanに近づきます。bloatが進むとvisibility mapが効きにくくなるため、Index Only Scanを選んでいてもHeap Fetchesが増えやすくなります。 これらは独立した問題ではなく、互いを悪化させる循環になっていました。 autovacuumの設定と限界 デッドタプルの回収は主にautovacuumに依存しています。 offer_candidates には、デフォルトより早め・強めに動かす設定を入れています。 ALTER TABLE offer_candidates SET ( autovacuum_vacuum_scale_factor = 0 . 01 , autovacuum_analyze_scale_factor = 0 . 01 , autovacuum_vacuum_cost_limit = 1000 ); VACUUMの起動判定は、おおむね次の式です。 autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × ライブ行数 thresholdのデフォルトは50、scale_factorのデフォルトは0.2です。scale_factorを0.01に下げているため、デッドタプルが行数の約1%に達したあたりでVACUUMが検討されます。デフォルトの約20%まで待たずに動き始める、という意図です。analyzeも同様に0.01へ下げ、統計情報の更新を早めます。 autovacuum_vacuum_cost_limit = 1000 は、1回のコスト計算あたりに許す仕事量の上限です。値を大きくすると、スリープを挟む前により多く進めるため、VACUUM自体は積極的になります。その分、稼働中のI/O負荷も上がりやすい点には注意が必要です。あわせて、プライマリ側では autovacuum_work_mem を大きめに取り、VACUUM中のメモリ不足で効率が落ちにくいようにしています。 それでもautovacuumには限界があります。テーブル本体のVACUUMに付随するインデックス掃除は、基本的に直列で進みます。手動の VACUUM ならインデックス掃除を並列化できますが、日常運用の主戦場であるautovacuumではそれが使えません。インデックス本数が多いほど1回のautovacuumが長引き、その間に次のデッドタプルが溜まりやすくなります。 そのため、書き込み回数を減らす、インデックスを必要最小限にする、HOTが効く形へ寄せる、という3方向で改善を進めています。前の2つは適用済みで、最後の1つは収集起点を移す構成変更として進行中です。 対策1:書き込みを減らす まずアプリケーション側で、書き込み回数そのものを減らしました。方針は次のとおりです。 配信時刻の付け直しは、会員とチャネルの組ごとに必要な分だけ行う。同じ組に複数行あっても、何度も書き換えない まだ処理すべきでない組は、その回の対象から外す。途中段階で何度も同じ更新を繰り返さない 配信対象外と判定できる処理を後工程から前工程へ移し、不要な行のINSERT自体を抑える。後からDELETEするより、そもそも書き込まない方がDB負荷は小さい 処理の流れは、まず配信対象になりうる会員を探し、その後に更新対象の候補行を拾う、という二段構えです。会員抽出側の条件も同じルールに揃えています。 対策2:インデックスを小さくする 書き込み削減とあわせて、インデックスも見直しました。前述のとおり、autovacuumではインデックス掃除が直列に進むため、更新の多いテーブルでは本数そのものが運用コストになります。 まず未使用・非効率だったインデックスを3本削除しました。そのうえで、参照頻度の高い状態の行だけを対象とする部分インデックスを2本追加しています。なお本記事では、DDLやインデックス定義の列名を、役割が伝わりやすいものに置き換えています。 会員IDと配信時刻で探すクエリ向け: (member_id, due_at) シャード範囲と配信時刻で会員を拾うクエリ向け: ((member_id % 100), due_at) に必要列を INCLUDE したカバリングインデックス どちらも WHERE status = 'waiting' 相当の述語付きです。適用後の EXPLAIN では、主要な2本のクエリがこれらのインデックスを使うことを確認しました。 インデックスのサイズは次のように変わりました。 見直し前 見直し後 収集用インデックスの合計 約23GB(3本) 約9.6GB(2本) 主キー 7,046MB 7,046MB UUIDのユニーク制約 7,676MB 7,676MB 見直しの対象外である主キーとユニーク制約のサイズが変わっていないので、テーブル全体の規模が大きく動いたわけではありません。そのうえで収集用のインデックスだけが3分の1近くまで小さくなりました。特に大きかったのは、状態と配信時刻を含む1本が単独で14GBを占めていたことです。 運用上の注意点も2つあります。 1つ目は、想定したインデックスが選ばれないケースです。ユニークキーでの探索を期待していても、plannerが別プランを選ぶことがあります。部分インデックスの述語と、実際のWHERE句・統計情報の組み合わせを見ながら調整が必要でした。 2つ目は、スキーマ管理にpsqldefを使う場合の差分です。ステータスは可読性のため文字列で持っています。その結果、部分インデックスの述語はPostgreSQLカタログの正規化形で書く必要があります。 status = 'waiting' と書くだけでは足りず、 status::text = 'waiting'::text のように書かないと毎回DROP / CREATEの差分が出続けます。数値やenumならこの種の差分は出にくい一方、ログやアドホックな調査では文字列の方が追いやすい、というトレードオフです。 ここまでの効果と残る課題 ここまでの対策だけでも、現在の運用上はSLAを担保できる状態になりました。一方で、前述のリードレプリカ制約により、根本のbloat要因は残っています。回収できないまま書き込みが続くと、ファイルの末尾が伸びていきます。VACUUMがファイルを小さくできるのは、末尾のページがまとめて空いたときだけです。このテーブルでは末尾に新しい行が入り続けるため、その状態にはほとんどなりません。途中にできた空きは次の書き込みに再利用されるだけで、ファイルサイズは下がりません。テーブルとインデックスのサイズは、「回収が間に合わなかった時点の最大値」で切り上がっていきます。 実測で課題になっているのが、先に追加した部分インデックス2本の肥大と、VACUUM自体の長時間化です。この2本は、状態が waiting の行だけを対象にして小さく保つ意図でした。ところが先ほどの約9.6GBは均等な内訳ではなく、カバリングインデックス側だけで8GBを超えています。配信を待っている行の実数から考えれば、本来これほど必要ありません。回収が追いつかず、過去の肥大が切り上がったまま残っている状態です。肥大したインデックスはスキャンを遅くします。そして遅くなったクエリがさらにVACUUMを止めるため、悪化が加速します。Index Only Scanに見えてもHeap Fetchesが増える状態も、この連鎖の一部です。加えて、今後もデータの更新量はさらに増える想定であり、いまのままでは余裕が足りません。 対策3:収集の起点を小さいテーブルへ移す そこで、収集の起点を、調べに行く会員だけを持つ小さな「きっかけテーブル」へ移す構成変更に取り組んでいます。 候補本体の大きな行はそのまま残します。きっかけテーブルは会員を探すきっかけだけを持ち、収集対象かどうかの判定は、従来どおり候補本体側で行います。早すぎる行や古い行が残っても空振りするだけで済むよう、意図して選んでいます。 きっかけテーブルには、候補を書き込むときに同時に行を登録します。候補の配信時刻を付け直したときも、既存行を書き換えるのではなく、新しい時刻の行を別途登録します。起点のテーブルまで頻繁に更新していては、bloat対策として本末転倒だからです。古い時刻の行は空振りになるだけで、新しい時刻の行が収集を起こすため、配信を取りこぼすことはありません。 収集処理が読んだ行は、結果に応じて次のように扱います。 候補の処理に成功した行は削除する 失敗した行は残し、次の実行で拾い直す 候補が見つからず空振りした行は、レプリカ遅延の許容内なら残す。許容を超えたら恒久的に不要とみなして削除する 失敗や許容内の空振りを残すのは、毎分全件を見ていたころの自己修復性を保つためです。逆に許容を超えた空振りを消すのは、きっかけテーブルが空振り行で膨らみ続けるのを防ぐためです。 削除するときは、読んだときの登録時刻と一致する行だけを消します。収集処理が動いているあいだに同じ会員・時刻の候補が追加されると、その行の登録時刻は新しくなります。その行は削除条件から外れるので残り、追加された候補は次の実行で拾われます。登録時刻を見ずに消すと、読んだ後に増えた候補を起こすものがなくなります。 ここまでの動きを支える列は、次の4つだけです。 CREATE TABLE offer_candidate_due_members ( member_group smallint NOT NULL , due_at TIMESTAMP WITH TIME ZONE NOT NULL , member_id bigint NOT NULL , created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP , PRIMARY KEY (member_group, due_at, member_id) ); 主キーは (member_group, due_at, member_id) の順にしています。収集処理は担当するグループを等価条件で列挙して引きます。この形なら、各グループの先頭から「いま処理すべき時刻」まで読んだところで走査が止まります。先頭列を範囲条件にすると、B-treeは due_at で打ち切れず、未来の行まで毎回なめてしまいます。収集はこの主キーだけで足りるので、専用のインデックスは持ちません。 このテーブルにも配信候補テーブルと同じautovacuum設定を入れています。全行が1日で「INSERT →(再登録があれば登録時刻のUPDATE)→ 読まれる → DELETE」を辿るため、1日の終わりには中身のほとんどがデッドタプルになるからです。 そのため fillfactor も80に下げています。HOT更新は同じページ内に新しい行バージョンを置く余白を必要とするので、その分を空けておきます。1行が細くページの埋まりやすいテーブルなので、配信候補テーブルより効きやすい調整です。 収集起点が移ることで、配信候補テーブル側のインデックスは次のように変わります。太字がbloatの主因でした。 変更前 変更後 主キー id id ユニーク uuid uuid 収集用 (member_id, due_at) WHERE status='waiting' (member_id) 収集用 (member_id % 100, due_at) WHERE status='waiting' 削除 部分インデックス2本を削除でき、更新の対象になる due_at と status はどのインデックスにも含まれなくなります。これで状態遷移や時刻の付け直しがHOT更新の候補になります。HOTの成立には同じページ内に新しい行バージョンを置く空きも必要なため、すべての更新がHOTになるわけではありません。それでも、HOTになった更新はインデックスにdead entryを追加しないため、VACUUMが回収すべき仕事量は減っていきます。 2. Cloud SchedulerジョブをCloud Tasksへ移行する 2つ目は、ジョブ起動基盤の改善です。 最初はシンプルなScheduler増加を選んだ 配信ジョブの性能を上げるため、処理をシャード分割して分散させる方針を取りました。リリースを優先し、まずはCloud Schedulerのジョブをシャード数分用意して、各ジョブが配信候補の収集処理を直接呼ぶシンプルな形にしました。 立ち上げとしては合理的でしたが、運用が続くと次のつらさが出てきました。 シャード分割や流量制限を変えるたびに、Schedulerジョブの作り直しが必要になる 流量を落としたいときに、ジョブを手動でenable / disableする手間がかかる Schedulerが50本あると、監視もコンソール上の見通しも悪くなる 導入してしばらくは、障害時にPagerDutyのアラートもシャード数分飛び、Ackするだけで手が塞がった 通知の爆発については、Schedulerが50本のままでも、PromQL側でまとめて1通知にするよう監視を直して吸収しました。ジョブ自体の集約とは別に、先に運用面の痛みを下げました。 Cloud Tasksへの集約 そこで、Cloud Schedulerは毎分1回だけ振り分け用のエンドポイントを呼ぶ形に変えました。振り分け側は会員IDの剰余空間(現状は100分割)を shardSize 刻みに切り、各シャードの収集処理をCloud Tasksキューへ積みます。Tasks側のタスクが、実際の収集処理をOIDC付きで呼び出します。 流量制御はキュー側に集約できます。分割粒度( shardSize )はSchedulerのクエリパラメータで渡せるため、アプリの再デプロイなしに変えられます。剰余の母数そのもの(100)は、配信候補テーブルのインデックス定義( member_id % 100 )に埋め込まれているため、簡単には変えられません。 採用している主なパラメータは次のとおりです。 項目 値 役割 Scheduler 毎分1ジョブ / attempt_deadline=60s 振り分けだけを起動 shardSize 2(現状) 100空間を50タスクに分割 max_concurrent_dispatches 50 同時に走る収集処理数の上限。負荷調整時はここを下げる max_dispatches_per_second 500 秒間の起動上限 リトライ max 4回、backoff 1s〜10s 一時失敗の再試行 enqueue並行度 50(ベストエフォート) 全シャードのenqueue完了を待って2xx。設計上は通常1秒未満 Cloud Traceで1週間分の実行を見ると、振り分け処理の所要時間は大半が1秒未満で、まれに数秒かかることがあります。Schedulerの attempt_deadline は60秒なので、この程度のばらつきは吸収できます。ただし振り分けが遅れた分だけ収集処理の開始も遅れるため、極端に長い場合は監視で気づけるようにしています。 振り分け側では重複防止をしません。毎分必ずタスクを積み、二重に来た分は収集処理側の実行中ガード(HTTP 204)で吸収します。enqueueに失敗したシャードは、次の毎分サイクルで再度積まれる前提です。 監視も、Scheduler 50本それぞれの状態を見る形から次の観点へ整理しました。 振り分けジョブ自体の未起動 / 実行失敗 収集処理のシャード別連続エラー enqueue失敗(ベストエフォートで2xxを返すため、別途必須) Cloud Tasksキューの滞留 収集処理のジョブSLA超過 運用開始後は、キュー滞留アラートの継続時間を収集処理のSLA(20分)に揃えたり、一過性のenqueue失敗で誤発火しないよう条件を入れたりと、閾値のチューニングも続けています。 タイムアウトと運用上の注意 注意したいのが、Cloud TasksからCloud Runの収集処理を呼ぶときのタイムアウトです。Cloud Tasks側の dispatch_deadline と、Cloud Run側のリクエストタイムアウトは揃える必要があります。 Cloud TasksのGoクライアントのリファレンス にも、 dispatch_deadline はアプリハンドラのタイムアウトより数秒長い程度に設定する、という推奨があります。App Engineターゲット向けの記載ですが、期限が切れるとCloud Tasksが応答を待つのをやめて再試行する動きは共通です。Cloud Runを呼ぶ場合にも同じ理屈が当てはまります。Cloud Tasks側だけ先に切れると、Cloud Run上では処理が続いているのにTasksが失敗とみなして再送し、二重実行の温床になります。 ただし、期限を揃えれば二重実行がなくなるわけではありません。Cloud Runはタイムアウトで504を返した後も、ハンドラの処理がすぐに止まるとは限らないためです。重なった実行を最終的に吸収するのは前述の実行中ガードです。期限合わせは、無駄な失敗判定と再送を減らすための調整です。収集処理のように時間が伸びうる処理では、この関係を意識して設計する必要があります。 shardSize の変更にも注意が必要です。範囲の重なる新旧のタスクが並走しないよう、振り分けを一時停止し、キューが空になるのを確認します。キューが空でも、前述のとおり収集処理が続いていることはあり得ます。実行中ガードはシャード範囲を単位にしているため、範囲の形が変わると旧タスクの抑止には使えません。そのため、走っている収集処理が終わるまで待ちます。 下流の最適化処理が終わるのも待ちます。収集処理から送るメッセージの順序も、シャード範囲を単位にしているためです。範囲が変わると同じ会員のメッセージが別の単位で流れるので、前の範囲を処理し終えてから変更します。 3. Pub/Sub ordering keyのカーディナリティを上げる試み 3つ目は、Pub/Subのordering keyに関する改善です。こちらは実装・検証まで進めたうえで、採用を見送った事例です。 なぜordering keyを使っているか ordering keyは、パーソナライズ配信の整合性を保つために使っています。同一会員の候補を順序付きで扱う必要があるためです。 当初は複数会員の候補を1メッセージにまとめていたため、ordering keyをシャード範囲と結びつける必要がありました。その結果、受信側の並列度がシャード数で頭打ちになっていました。前章の shardSize 変更で、下流の処理完了まで待つ必要があるのも、この結合が理由です。 高カーディナリティ化の狙い 並列度を上げるため、次の方針で実装して検証しました。 メッセージを細かくし、ordering keyをシャード範囲から会員IDへ変える 同一会員内の順序は、送信側の並びと受信側の処理順で担保する 受信側では細かいメッセージをまとめて処理し、DBへまとめて渡せる形を残す 送信側・受信側の流量制御(outstanding messages)もあわせて調整する メッセージを細かくすると受信側はスケールしやすい一方、DB側でまとめて処理する利点とのバランスは取りにくくなります。そこで、送信は細かく、受信で再バッチする形を採りました。 受信側はスケールしたが、送信側が重い 受信側は期待どおりスケールしました。一方で、送信側の負荷が想定以上に上がりました。 次の図は、STG環境で取得したCloud Runのメトリクスです。左の3つの山が変更前、右の2つの山が変更後です。この処理は毎分起動して候補をまとめてpublishするため、1リクエストの所要時間はもともと秒から分のオーダーです。ここで見るべきは絶対値ではなく、同じ仕事に対する変化です。 変更後はCPU使用率が100%に到達し、リクエストのテールレイテンシも跳ね上がりました。注目したいのはLatency breakdownの内訳です。伸びているのは青色のUser execution、つまりアプリケーション自身の実行時間でした。ネットワークやルーティングではなく、publish処理そのものが重くなっていました。 変更前にもUser executionが伸びる山はありました。変更後に新しく現れた現象ではなく、スパイクがより高く鋭くなった、という変化です。 原因はGoクライアントの実装から見えてきました。publishのbundleは「同じbatch内のmessageは同じordering keyを持つ」前提で組まれています。次のコメントは cloud.google.com/go/pubsub の Topic.publishMessageBundle にあるものです。 extract the ordering key for this batch. since messages in the same batch share the same ordering key, it doesn't matter which we read from. ( googleapis/google-cloud-go pubsub/topic.go より引用) つまり、ordering keyが異なるメッセージは同じbundleにまとめられません。keyのカーディナリティが高く、各keyあたりのpublish rateが低い場合、batch効率は落ちやすくなります。結果として、同じメッセージ数でもRPC数は増えます。クライアント内部でpublishを管理するschedulerの状態やタイマーも膨らみ、publisher側の負荷につながります。先ほどのUser executionの伸びは、この増加分として説明できます。なお、これは公式ドキュメントに明記された挙動ではなく、実装からの推論です。 この推論は、Pub/Subトピック側のメトリクスからも確認できました。 上段のPublished message countは、publishしたメッセージ数です。1メッセージ1候補にしたため、変更後は毎秒20,000近くまで増えています。ここは設計どおりです。 問題は下段のPublish requestsで、こちらはpublishのリクエスト数、つまりRPCの数です。変更後は毎秒15,000から18,000に達しています。メッセージ数が毎秒20,000程度であることと合わせると、1回のRPCで運べているメッセージはごくわずかです。bundleがほとんどまとまらないまま送信されていた、という状態です。 送信側と受信側の流量制御パラメータや、上流の shardSize も変えて試しました。しかし、いずれも負荷の出方を変えるだけで、bundleが分かれる構造そのものには効きませんでした。 見送りの判断と残る選択肢 受信側のスケール自体は成功しました。しかし、パラメータ調整では回避できない送信側の負荷増と実行時間の悪化に見合うだけの利点がないと判断し、本番採用は見送りました。ordering keyのカーディナリティは、下流の並列度だけでなくpublishスループットにも効きます。ドキュメントが勧める「キーを細かくする」方向と、クライアント実装上のbatch効率は、単純には両立しません。 並列度の頭打ち自体は残っている課題です。今回試したのはシャード範囲と会員IDという両端で、その中間にあたる粒度は試せていません。keyを今の50通りより細かく、会員IDよりは粗く保てば、並列度とbatch効率が両立する粒度を見つけられるはずです。 中間の粒度は、いまの収集分割のまま試せます。 shardSize を1にすれば、シャード範囲をキーにしたままでも、現行の100分割で100通りになります。キーを独立させるなら、送信側で member_id % N をordering keyにすれば足ります。収集ジョブの分割やインデックスを変えなくても、Nを選べます。 収集の分割とPub/Subのキー空間が結びついているのは、複数会員を1メッセージにまとめていた名残です。キーを独立させれば、収集側の流量調整とordering keyのカーディナリティを別々に動かせます。中間の最適点を探すのは、その先の課題として残しています。 まとめ 本記事では、毎分50シャードで動くパーソナライズ配信ジョブに対する、3つの負荷・運用改善を紹介しました。 配信候補テーブルへの過剰なUPDATE / INSERTを抑え、インデックスを必要最小限の部分インデックスへ再設計して、現行運用のSLAを担保できる状態まで改善した。一方で、リードレプリカ制約下では部分インデックスのbloatが残りうるため、収集起点を小さいテーブルへ移す構成変更にも取り組んでいる Cloud Scheduler 50本を、振り分け1本とCloud Tasksへ集約し、流量調整と監視を再デプロイなしで扱いやすい形にした Pub/Subのordering keyを細かくする改修は、受信側のスケールには効いた一方、Goクライアントのbatch実装により送信側の負荷が増え、本番採用は見送った うまくいった改善だけでなく、見送りの判断とその根拠も、同様の構成を運用する方の参考になれば幸いです。今後は、きっかけテーブルへの構成変更を本番へ適用し、bloatの根本要因にどこまで効くのかを実測で確かめていきます。ordering keyについては、シャード範囲と会員IDの間の粒度を検証していきます。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、SRE部プラットフォームSREブロックの さかべっち です。 ZOZOTOWNのプラットフォーム基盤では、サービスメッシュとしてIstioをSidecarモデルで運用しています。本記事では、Istio Data Planeのリアーキテクチャに向けて実施した、Ambient MeshのPoCとNative Sidecar導入の取り組みをご紹介します。 Ambient Meshはすでに一般提供(GA)されていますが、大規模な本番基盤での移行可否を判断できる材料や事例はまだ多くありません。私たちはPoCで機能・性能を体系的に評価しました。そして、その実測データをもとに「現時点の最適解」としてSidecarモデルの継続とNative Sidecarの採用を選定しました。 目次 はじめに 目次 背景 ZOZOTOWNにおけるIstioの運用状況 Sidecarモデルの課題 Ambient Meshとは Ambient Mesh PoC PoCの目的 検証結果の概要 確認できたメリット レイテンシの改善 リソース効率の改善 段階的な移行の現実性 判明した移行ブロッカー VirtualServiceからHTTPRouteへの移行 EnvoyFilterの移行 公式のメリットを最大限享受できない事情 現時点の最適解の選定 Native Sidecarの導入 Native Sidecarとは revisionベースのカナリアリリースによる段階移行 効果1: Job系PodがMeshに参加可能に 効果2: Pod削除時間の短縮と設定の簡素化 まとめ 背景 ZOZOTOWNにおけるIstioの運用状況 ZOZOTOWNのプラットフォーム基盤では、マイクロサービス間のトラフィック制御やカナリアリリースを実現するため、Istioを中核コンポーネントとして運用しています。Istioの運用については、前回の記事「 サービス断なしで進めるIstio OperatorからHelmへの移行 」でもご紹介しました。 現在の基盤は、Meshに参加する全てのPodへistio-proxyコンテナを注入するSidecarモデルで構成されています。規模感として、以下のIstio APIリソースに依存しています。 VirtualService / DestinationRule: 各約200個(ルーティングやカナリアリリースに使用。約70個のFlagger Canaryがこれらを操作) EnvoyFilter: 十数個(Rate Limit、レスポンス圧縮など) AuthorizationPolicy: 数個(L7条件を用いたアクセス制御) そして、全てのマイクロサービスがルーティングやカナリアリリースなどのL7機能を利用しています。 Sidecarモデルの課題 Sidecarモデルは実績のある構成ですが、運用する中で以下の課題が見えてきました。 1つ目は、リソースのオーバーヘッドです。全てのPodにistio-proxyコンテナが注入されるため、そのCPU・メモリ使用量がPod数に比例して積み上がります。 2つ目は、Job系PodがMeshに参加しづらいことです。Sidecarモデルでは、Jobのアプリケーションコンテナが終了してもistio-proxyはRunningのまま残ります。その結果、JobがいつまでもCompletedにならない問題が発生します。このため、Job系PodはMeshに参加できないままとなっていました。 3つ目は、Podライフサイクル管理の複雑さです。istio-proxyとアプリケーションコンテナの起動・停止順序に保証がないため、 terminationDrainDuration などの設定を個別にチューニングする必要がありました。 Ambient Meshとは Ambient Meshは、Sidecarモデルに代わるIstioの新しいData Planeアーキテクチャです。istio-proxyを各Podへ注入する代わりに、L4機能をNode単位のztunnel(DaemonSet)が担当します。L7機能は、基本的にNamespace単位で配置するWaypoint Proxyが処理します。 Podからistio-proxyがなくなることで、リソース効率やレイテンシの改善が期待できます。また、先述したJob系Podの課題も、Sidecarの存在しない構成では考える必要がありません。 Ambient Meshには魅力的なメリットが期待できる一方、ZOZOTOWN規模の本番基盤で移行可否を判断できる事例は少ないのが実情です。そこで、専用クラスターを用意してPoCを実施し、実測データをもとに移行を判断することにしました。 Ambient Mesh PoC PoCの目的 IstioのData PlaneをSidecarモデルから、改善が見込まれるAmbient Meshに移行できるかを検証し、以下を整理して現時点での移行実施を判断することを目的としました。 移行がコスト・難易度を踏まえて現実的か プラットフォーム基盤がAmbient Meshに移行することで享受できるメリット、およびデメリットやリスク ここで重要な前提があります。 Istio公式ドキュメント によると、Ambient Mesh(Waypoint Proxy)においてVirtualServiceはAlpha扱い、EnvoyFilterは非サポートです。つまり、Ambient Meshへ移行するには、実質的にIstio APIからGateway API(HTTPRoute)への移行が必須となります。先述の通り、私たちの基盤は大量のIstio APIリソースに依存しているため、この移行難易度の見極めがPoCの最重要ポイントでした。 検証結果の概要 検証環境としては、実環境をコピーした専用クラスター(EKS v1.35 / Istio v1.27.3)を用意し、既存環境に影響を与えない形で検証しました。なお、本記事に登場するサービス名(service-aなど)は仮称です。 機能要件・非機能要件として定めた検証項目と結果のサマリーです。 検証項目 結果 備考 基本的なPod間通信 ◯ sidecarless化後も問題なし SidecarとAmbientの混在通信 ◯ Ingress Gateway(Sidecar)からAmbient化したサービスへの通信も成立 NetworkPolicy適用下の通信 ◯ HBONEプロトコルの許可設定を追加して対応 Gateway APIでのルーティング表現 △ 一部のルーティング機能を表現できない(後述) EnvoyFilterの代替 ✕ Rate Limitなどで代替手段がない(後述) Flaggerによるカナリアリリース ◯ gatewayapi:v1 providerで重み付け・promote・rollbackを確認 Istio Upgrade ◯ istiod / ztunnel / istio-cni / Waypointいずれも通信断なし レイテンシ ◯ 約40%改善(後述) 移行時の瞬断 △ L7機能利用時、Waypoint経由の経路確立まで約2秒の404が発生 確認できたメリット レイテンシの改善 Sidecarモデルで13.12msだったレイテンシが、Ambient Meshでは7.75msとなり、約40%の改善を確認しました。ただし、これは単一パスでの簡易的な計測です。全てのマイクロサービスで同様の改善を保証するものではありませんが、パフォーマンス向上への期待は持てる結果でした。 リソース効率の改善 Pod単位のistio-proxyが不要になるため、リソース使用量の削減効果を確認できました。L4を担当するztunnelはNode単位で共有されるので、Podごとのオーバーヘッドが発生しません。また、Waypoint Proxyは実トラフィックに応じてオートスケールできるため、チューニング次第でさらなるコスト削減も期待できます。 段階的な移行の現実性 SidecarモデルとAmbient Meshが混在しても通信が成立することを確認しました。一括での切り替えではなく、サービス単位で段階的に移行を進められます。 なお、検証の中でAmbient Meshの興味深い仕様も判明しました。SidecarとAmbientの混在環境では、送信元と送信先のモードの組み合わせによってL7処理を行う場所が変わります。 送信元 送信先 ztunnel Waypoint Proxy L7処理 Sidecarモード Ambientモード 送信先側のみ経由する 経由しない 送信元のSidecar Ambientモード Ambientモード 送信元・送信先とも経由する 経由する Waypoint Proxy Ambientモード Sidecarモード 送信元側のみ経由する 経由しない 送信先のSidecar Sidecarモードのworkloadが送信元となる通信では、Ambient化した送信先のWaypoint Proxyを経由しません。そのため、移行の順序によっては影響にすぐ気づけない経路もある点に注意が必要です。 判明した移行ブロッカー メリットを確認できた一方、移行を困難にする大きなブロッカーも判明しました。 VirtualServiceからHTTPRouteへの移行 約200個のVirtualServiceをHTTPRouteへ移行できるかを検証したところ、主要機能の対応状況は以下の通りでした。 VirtualServiceの機能 HTTPRouteでの対応 パス・ヘッダーマッチ 対応(標準機能で表現可能) 重み付け 対応( backendRefs の weight で表現可能) タイムアウト・リトライ 対応(Experimental) URLRewrite 対応( filters で表現可能) sourceLabels 非対応(標準specに存在しない) withoutHeaders 非対応(標準specに存在しない) directResponse 非対応(標準specに存在しない) regex 非対応(Implementation-specificだが検証でも動作せず) delegate 要再設計(ReferenceGrantで動作するが1:1では移行できない) 基本的なルーティングは問題なく表現できます。しかし、 sourceLabels 、 withoutHeaders 、 directResponse は標準specに存在せず、代替も困難です。 たとえば sourceLabels と似た機能を持つものに、AuthorizationPolicyの source.namespaces や source.principals があります。しかし、AuthorizationPolicyでできるのはアクセスの許可・拒否だけです。送信元に応じて転送先を切り替えるルーティングは実現できないため、代替にはなりません。 EnvoyFilterの移行 私たちの基盤では、主にRate Limit機能でEnvoyFilterを使用しています。このEnvoyFilterはVirtualServiceのroute名( spec.http[].name )に依存しています。一方、HTTPRouteにはrouteへ名前を付ける同等の仕組みがありません。つまり、HTTPRouteへ移行するとroute名ベースの連携が切れてしまうため、Rate Limitの設計を根本から見直す必要があります。 また、レスポンス圧縮(Compressor)のEnvoyFilterもSidecar前提の構成になっており、アプリケーション側での実装やWasmPlugin(Alpha)への再設計が必要になります。 公式のメリットを最大限享受できない事情 Istio公式がアピールするAmbient Meshのメリットのうち、私たちの基盤では享受しきれないものもありました。 1つ目は「L4のみの段階的導入」です。公式では、まずztunnelによるL4機能(mTLSなど)だけを導入し、必要になったらWaypoint ProxyでL7機能を追加する段階的な導入が推奨されています。L4のみのNamespaceはWaypoint Proxyが不要なため、リソース効率も最大化されます。しかし、私たちの基盤では全てのマイクロサービスがL7機能を利用しているため、全NamespaceでWaypoint Proxyが必要になり、この恩恵を受けられません。 2つ目は「Proxyのライフサイクルをアプリケーションと分離できること」による運用負荷の軽減です。Waypoint Proxyは原則としてNamespace単位で配置され、各サービスを担当するSREチームの所有物です。そのため、Upgrade作業自体はなくなりません。アプリケーションPodの再起動もProgressive Deliveryで安全に行える仕組みがすでにあるため、実質的な負担減にはなりにくいと判断しました。 現時点の最適解の選定 PoCの結果を整理すると、以下のようになります。 レイテンシ・リソース効率の改善というメリットは実測で確認できた 一方、Ambient化に実質必須となるGateway APIへの移行に、設計の根本見直しを要するブロッカーが複数存在する 約200個のルーティング定義の書き換えに加えて、Rate Limitやレスポンス圧縮の設計見直しが必要 公式がアピールするメリットの一部は、私たちの基盤の特性上享受できない これらを踏まえ、Ambient Meshへの移行は現段階では時期尚早と判断し、Sidecarモデルを継続する意思決定をしました。あわせて、以下のタイミングで移行を再検討することにしています。 Waypoint ProxyでのEnvoyFilterやVirtualServiceのサポートが開始されたとき 公式はサポート提供予定と言及しており、Gateway API移行を踏まずにAmbient化できる可能性がある 現在のSidecarモデルやIstio APIがDeprecatedになる動きが出てきたとき なお、Ambient Meshを特定のサービスのみに導入することも考えられますが、それは行わない方針です。検証ではAmbient MeshとSidecarで通信できることを確認しましたが、これは移行過程で問題が起きないかの確認です。L7機能への依存が薄く先行導入しやすいアプリケーションは存在せず、部分的に導入してもAmbient MeshとSidecarの2系統を運用する負荷が増えるだけです。 「移行しない」という結論であっても、実測データに基づいて判断できたこと、そして再検討の条件を明文化できたことに大きな価値があると考えています。今後Ambient Meshのサポート状況が変化した際に、ゼロから調査をやり直すことなく意思決定できます。 そして、当初期待していたメリットのうち「Job系PodのMesh参加」は、Ambient Meshに移行しなくても実現できます。それを可能にするのがNative Sidecarです。 Native Sidecarの導入 Native Sidecarとは Native Sidecarは、Kubernetes 1.28で導入されたSidecarContainers機能を利用した、Sidecarコンテナの新しい実装方式です。istio-proxyが restartPolicy: Always 付きのinitContainerとして注入されるようになります。これにより、KubernetesがSidecarのライフサイクルを保証します。 従来のSidecarとの違いは以下の通りです。 項目 従来のSidecar Native Sidecar istio-proxyの配置 spec.containers (アプリと並列) spec.initContainers + restartPolicy: Always 起動順序 アプリと並列起動(順序保証なし) istio-proxyがReadyになってからアプリが起動 停止順序 並列終了(順序保証なし) アプリ終了後にistio-proxyが終了 Podの終了条件 全コンテナの終了が必要 通常コンテナが全て終了すればPodが完了 利用にはKubernetes 1.28以上(1.33以上で自動有効化)と、Istio 1.27以上での ENABLE_NATIVE_SIDECARS: true 設定が必要です。私たちの基盤はEKS v1.35で運用しているため条件を満たしており、Istio v1.29へのUpgradeにあわせて有効化する方針にしました。 revisionベースのカナリアリリースによる段階移行 従来、私たちが行ってきたIstio Upgradeと同様に、revisionベースの カナリアリリース で段階的に移行できるかを検証しました。 ENABLE_NATIVE_SIDECARS: true を設定した新revisionのistiodをデプロイします。以下はFluxCDのHelmReleaseでの設定例です。 apiVersion : helm.toolkit.fluxcd.io/v2 kind : HelmRelease metadata : name : istiod-1-29-2 namespace : istio-system spec : interval : 5m chart : spec : chart : istiod version : 1.29.2 sourceRef : kind : HelmRepository name : istio namespace : istio-system dependsOn : - name : istio-base namespace : istio-system values : env : ENABLE_NATIVE_SIDECARS : true この状態で、 istio.io/rev: "1-27-3" のサービスと istio.io/rev: "1-29-2" のサービスを混在させ、動作を確認しました。 # 旧revision(従来のSidecar) ❯ kubectl get pod -n service-a -l app=service-a \ -o jsonpath='initContainers: {.items[0].spec.initContainers[*].name}' initContainers: istio-init # 新revision(Native Sidecar) ❯ kubectl get pod -n service-b -l app=service-b \ -o jsonpath='initContainers: {.items[0].spec.initContainers[*].name}' initContainers: istio-init istio-proxy 新revisionのPodのみ、istio-proxyがinitContainersとして注入されています。この混在状態でも通信に問題はなく、起動順序もistio-init → istio-proxy → アプリケーションコンテナの順になることを確認しました。また、このrevision切り替えは既存のFlaggerによるカナリアリリースに乗せて実施し、昇格判定を含めて問題なく動作することを確認しました。 効果1: Job系PodがMeshに参加可能に 背景で述べた「JobがCompletedにならない問題」が解決するかを、同じ内容のJobを新旧revisionで作成して検証しました。 ❯ kubectl get pod -n service-b -l app=test-job-native NAME READY STATUS RESTARTS AGE test-job-native-pxw69 0/2 Completed 0 2m19s ❯ kubectl get pod -n service-a -l app=test-job-legacy NAME READY STATUS RESTARTS AGE test-job-legacy-dtgz9 1/2 NotReady 0 2m13s 従来のSidecarではアプリコンテナ終了後もistio-proxyが残りNotReadyのままですが、Native SidecarではPodが正常にCompletedになりました。これにより、Job系PodのMesh参加が可能になりました。 効果2: Pod削除時間の短縮と設定の簡素化 Native Sidecar化によって、Pod削除の挙動も大きく改善しました。 Istioのpilot-agentには 4つのexit path が実装されており、SidecarモードによってPod削除時の挙動が異なります。従来のSidecarは、SIGTERM受信後に terminationDrainDuration 分のdrainを待ってから終了します。一方Native Sidecarでは、Pod削除開始と同時にdrainが始まり、アプリ終了後のSIGTERMで即時終了します。 なお、図中の時間は挙動を説明するための例です。この違いを実際のPod削除時間で計測しました。 Sidecarモード Pod削除の所要時間 アプリ終了からistio-proxy終了までのラグ 従来のSidecar 61.62s 45s Native Sidecar 16.83s 0s 従来のSidecarでは、アプリケーションコンテナの終了後もistio-proxyがdrainを待ち続けます。最終的には terminationGracePeriodSeconds の上限に到達し、SIGKILLされていました。Native Sidecarでは、アプリケーションコンテナと同一秒にistio-proxyがgracefulに終了しています。その結果、Pod削除の所要時間は61.62sから16.83sへ約45秒短縮されました。Node入れ替えやデプロイのたびに発生する待ち時間が短縮されるため、運用全体で見ると大きな改善です。 この挙動により、これまで各Deploymentで個別にチューニングしていた terminationDrainDuration などのライフサイクル関連設定も実質無効になります。実際に計測でも待機がスキップされることを確認できたため、これらの設定は削除しました。istio-proxyのライフサイクルを意識した設定が不要になったことで、運用がシンプルになりました。 まとめ 今回はZOZOTOWNのプラットフォーム基盤でIstio Ambient MeshのPoCを実施した結果と、それを踏まえた現時点の最適解となるアーキテクチャを決めるまでの流れを紹介しました。 Ambient Meshでは、レイテンシやリソース効率の改善を実際に確認できました。ただ、移行にはほぼすべてのIstio APIリソースをGateway APIへ作り直す必要があり、Sidecarモデルの継続を選択しました。一方、当初の課題だったJob系PodのMesh参加は、Native Sidecarの導入で解決できました。 Ambient Mesh自体は将来性のあるアーキテクチャだと考えています。Waypoint ProxyがVirtualServiceやEnvoyFilterをサポートしたタイミングで、改めて移行を検討する予定です。サービスメッシュのアーキテクチャ選定を進めている方、特に現在Sidecarモデルを運用していてAmbient Meshへの移行を検討している方の参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、データサイエンス部の大川( @o_tomo03 )です。私たちは、WEARにおける「似合う」をユーザーに届けるため、LLMやマルチモーダルAIを活用してコーディネートの特徴抽出や似合うに関する独自の判定処理のR&Dを行っています。 その中核を担うのが、全身のコーディネート画像から特徴を抽出するVLM(Vision Language Model)のプロンプトです。このプロンプトの精度を上げようとすると、地道な手作業のチューニングが延々と続きます。みなさんも一度は経験したことがあるのではないでしょうか? 当初、私が実際に回していたのは次のようなループでした。 LLMに推論させ、正解データと突き合わせる 間違えたサンプルを開き、LLMの出力・判断理由・画像を分析する 「なぜ間違えたか」を考え、プロンプトの定義や例示を書き直す もう一度評価し、評価指標が上がったか確かめる 効いた/悪化した施策を記録し、次に活かす この「 失敗を見る → 直す → 再評価する 」を、ファッション特徴の数(例:アイテム、素材、柄など)だけ繰り返します。1つのプロジェクトのチューニングに、数週間を費やすこともありました。 以前、関連する取り組みとして「LLMの構造化出力エラーを削減する」という記事を公開しました 1 。あちらは 出力の正しさ (定義外の値を出さないこと)を、バリデーションとエラーフィードバックで守る話でした。本記事はその一歩先、 "精度そのもの"を上げるチューニングのループごと、AIエージェント(Claude Code)に任せられないか という仮説です。 本記事で扱う問いはシンプルです。 人間が手で回していたプロンプトチューニングのプロセスを、Claude Code の自律ループで 再現 できるか。人と同等の精度に、どれだけ短い時間で到達できるか。 ポイントは、新しい最適化アルゴリズムを発明することではなく、 人の作業手順をそのまま機械へ移す ことです。 目次 はじめに 目次 サマリー 背景 ── ハーネスとループエンジニアリング アイデア ── 人のチューニングループを分解する reasoningを勾配として使う システム設計 ── 人の動きを sub-agent に割り当てる 実験セットアップ 対象タスク 推論・最適化モデル 実験設定 実験結果 実験結果1 ── 人の動きを再現できたか(T1) 工数:どれだけ短縮したか 実験結果2 ── スキーマ構造が異なる他タスクでも汎化するか(T1~T3) 実験結果3 ── reasoning は本当に効くのか(ablation、T1のみ) 実践から得た知見 エージェント設計は「自分がどう解いていたか」の移植から始める 過学習とプロンプト肥大化に注意する コストに注意する 今後の展望 まとめ おわりに サマリー 人手のプロンプトチューニング(失敗分析→書き換え→再評価)を、Claude Codeのsub-agentで 役割ごとに分解して自動化 した( /tune スキル)。 スキーマ構造が異なる 3タスク(T1~T3) で、特徴の定義文のみを列挙した初期プロンプトを出発点に自動最適化を実行。 結果: 精度:T1では自動最適化後のF1が人手チューニングをN=1ながら 同等以上の水準 を達成(train +0.024、test +0.017)。 工数:全体評価サイクルの工数を人手約3.5週間から約1週間に短縮。 汎化:スキーマ構造が異なるT1~T3で適応可能性を確認。 仕組みの核心:LLMの推論時にファッション特徴と 判断理由(reasoning) を合わせて構造化出力している。 reasoningを 「次にどう直すか」の信号(≒勾配) として使い、参照あり/なしのablationでその寄与を検証した。 背景 ── ハーネスとループエンジニアリング 近年、LLM活用の関心は「良いプロンプトを書く」から一段上がり、 エージェントを動かす仕組みそのものの設計 に移ってきました。 ハーネス(harness) :モデル本体を除いた周辺すべて。システムプロンプト、ツール、権限、コンテキスト管理、検証ループ、ログなど。「Agent = モデル + ハーネス」のモデル以外の側と覚えれば直感的。 ループエンジニアリング(loop engineering) :ハーネスを停止条件やスケジューラで 自律的に回し続ける よう設計する、一段上の制御プレーン。「自分でClaudeにプロンプトを出す」のをやめ、 「Claudeへプロンプトを投げるループ」を設計する という発想の転換がコア。 本記事でやるのは、まさにこのループエンジニアリングの実践例です。用語の整理や系譜の詳細は割愛します 2 。 アイデア ── 人のチューニングループを分解する 自動化のため、まず取り組んだのは 自分が手で行っていた作業を要素へ分解する ことでした。以下の表の(1)〜(5)が、そのまま /tune のsub-agentに対応します。 人手ループのステップ 人が何をしていたか 自動ループの担当 (1)失敗を見る 誤答サンプルのLLM出力・ reasoning ・画像を眺める analyzer (2)原因を考える 定義と判断のズレ・過検出/見落としを特定 planner (3)プロンプトを直す 定義補足・例示追加・曖昧さ排除 improver (4)再評価する 評価を回してF1を確認 run_eval.py (5)良し悪しを覚える 「前にこれで悪化した」を記憶し次に活かす retrospector reasoningを勾配として使う (1)で人が行っていたのは、 LLMの「なぜそう判断したか(reasoning)」を読んで、次にプロンプトをどちらへ動かすかを決める ことです。機械学習の言葉に置き換えると次のようになります。 F1スコア ≒ 損失 (どれだけ外したか、スカラー) reasoning ≒ 勾配 (どの方向に直せば良くなるか) つまりこのループは、 プロンプトという"パラメータ"を、reasoningという"勾配"で更新する勾配降下 とみなせます。同じ着想はTextGrad 3 やOPRO 4 / APO 5 にも見られ、LLMの出力を自己改善の信号として使う研究の一例です。 システム設計 ── 人の動きを sub-agent に割り当てる /tune は1イテレーションで次のフェーズを回します。 スキルのディレクトリ構成は次のとおりです。 .claude/skills/tune/ SKILL.md # オーケストレーター:ループ制御に徹し、処理は sub-agent に委譲 agents/ analyzer.md #(1)失敗分析 planner.md #(2)改善計画 improver.md #(3)プロンプト改善 retrospector.md #(5)振り返り:DO/DON'T を lessons.md に追記 ... references/ optimization-policy.md # 採用/棄却・停止条件(単一真実源) prompt-design-strategies.md # プロンプト改善パターン集(Gemini公式ドキュメント) ... scripts/ ... 設計上のポイントは4つです。 SKILL.md をオーケストレーターとする : SKILL.md を「全体フローの制御」のみに徹し、専門的な処理は外部( agents/ )へ委譲している。わずか115行で記述。 optimization-policy.md を単一真実源とする :改善の優先順位、採用/棄却の判定、停止条件を1ファイルに集約。すべてのsub-agentがこの方針に従うよう設計している。 lessons.md = 人の記憶 :各イテレーション後に retrospector がDO(効いた施策)/ DON'T(悪化させた施策)を追記している。人手でチューニングしていると徐々に知見が溜まっていくので、そのメモリー機能を再現している。 prompt-design-strategies.md :Gemini公式のプロンプト改善手法 6 の要約を常に参照させることで、改善手法の選択を体系的に行えるようにしている。 なお、実験パラメータ(モデル、LLMパラメータ、最大イテレーション、データセット、実験ログ保存先など)は experiment_config.json に集約しています。実行前にユーザーが確認して自動最適化を開始する仕組みです。 実験セットアップ 対象タスク 全身のコーディネート画像から複数の特徴を抽出するタスクです。例えばT1では、アイテム・着こなし・サイズ感などの特徴を1つのプロンプトでまとめて抽出します。特徴の性質に応じて2値分類や多クラス分類などを使い分けています。 タスク 特徴の種類 特徴数 データ規模 人手チューニング比較 T1 アイテム・着こなし・サイズ感など 数十程度 数百件規模 あり T2 トップス・アウター・バッグなどの着用アイテム 数十〜百近く 数千件規模 なし T3 素材・柄・装飾・シルエットなど 数十〜百近く 数千件規模 なし 推論・最適化モデル 推論モデルにはGemini、最適化モデルにはClaude Codeの claude-sonnet-4-6 を使用しています。タスクによって推論モデルが異なります。 タスク model_id temperature thinking_level max_output_tokens max_iter T1 gemini-2.5-flash-lite 0.0 null 8192 10 T2 gemini-3.1-flash-lite 1.0 MINIMAL 8192 10 T3 gemini-3.1-flash-lite 1.0 MINIMAL 8192 10 実験設定 今回の実験では、以下の3軸で自動最適化を評価します。 実験1 — 人の動きを再現できたか(T1) :プロンプトエンジニアリングを一切行わずに、特徴の定義文のみを列挙した初期プロンプトを出発点に自動最適化を実行します。これまでの人手チューニング結果(F1: train 0.817 / test 0.847)と比較し、精度が人手を上回るか、工数はどれだけ短縮されるかを確認します。 実験2 — スキーマが異なる他タスクでも汎化するか(T1~T3) :同様の手順でT2・T3にも適用し、初期からの改善幅とtestへの汎化を確認することで、ループの適用可能性を検証します。 実験3 — reasoning は本当に効くのか(ablation、T1のみ) :「reasoningを勾配として使う」という設計の妥当性を検証します。reasoningをClaudeに見せる条件A( /tune )と見せない条件B( /tune-no-reasoning )の2条件をT1で比較します。 各実験に共通する設定は以下のとおりです。 評価指標 : feature F1 (特徴ごとのF1、例:サイズ感)と leaf F1 (各ラベルのF1、例:「サイズ感=オーバーサイズ」)のマクロ平均。leaf F1はより細粒度で、本実験の主評価指標とする。 停止条件 :以下のいずれかを最初に満たした時点でループを停止する。 (1)全feature・leaf F1 ≥ 0.7(目標達成) (2)最大10イテレーション到達 (3)3イテレーション連続で収束(leaf F1改善幅<0.005かつleaf改善数が不変) 実験結果 実験結果1 ── 人の動きを再現できたか(T1) T1について、これまでの人手チューニング結果と自動最適化後のleaf F1スコアを比較します。 タスク データ 人手チューニング leaf F1 自動最適化後 leaf F1 T1 train 0.8165 0.8403 (iter3) test 0.8473 0.8641 (iter3) 所見 :train +0.024、test +0.017で自動が人手を上回りました。trainでの改善はtestにも汎化しており、feature F1が0.7未満のfeatureを6→2に削減しました。 工数:どれだけ短縮したか 工数の比較は2つの粒度で行いました。 チューニングサイクル(1回) はエンジニアがleaf F1を改善し、ドメインエキスパートへ定性評価依頼が出せる状態にするまでの工程です( /tune が自動化する範囲)。 全体評価サイクル(最大3回) は「チューニング → ドメインエキスパートによる定性評価 → フィードバック反映」を1セットとし最大3回繰り返すサイクルです。 人手チューニング 自動最適化 速度比 チューニングサイクル(1回) 約6時間 約2.5時間 約2.4倍速 全体評価サイクル(最大3回) 約3.5週間 約1週間 約3.5倍速 人的介在 常時 config確認のみ — イテレーション数(全体評価サイクル) 30(プロンプトバージョン) 10~20 — ※ 全体評価サイクルの速度比がチューニングサイクル1回の速度比を上回るのは、自動化によりイテレーション数が減少したことに加え、ドメインエキスパートとのスケジューリングや待機時間が発生するためです。数値はいずれも実測値です。 実験結果2 ── スキーマ構造が異なる他タスクでも汎化するか(T1~T3) T1~T3のスキーマ構造が異なる3タスクで汎化性能を検証した結果を示します。 タスク データ 初期プロンプト leaf F1 自動最適化後 leaf F1 改善量 T1 train 0.7916 0.8403 (iter3) +0.049(+6.2%) test 0.7842 0.8641 +0.080(+10.2%) T2 train 0.8498 0.9143 (iter9) +0.065(+7.6%) test 0.8481 0.9184 +0.070(+8.3%) T3 train 0.5683 0.7633 (iter10) +0.195(+34.3%) test 0.8721 0.8794 +0.007(+0.8%) 所見 :3タスクすべてでleaf F1が向上しました。T3のtrain改善幅(+34.3%)は最大ですが、train初期値(0.57)が他タスクより低くtest初期値(0.87)と乖離しており、改善幅はその初期値の低さを反映しています。これはtrain側に初期プロンプトが苦手とする難しいサンプルが偏っていたためと解釈しています。いずれのタスクでもtestでの劣化はなく、feature F1が0.7未満のfeature数はT1で6→2、T3で5→1に削減しました。 実験結果3 ── reasoning は本当に効くのか(ablation、T1のみ) 「reasoningを勾配として使う」という主張の裏を取るため、ablationを実施しました。最適化モデルにGeminiのreasoningを 見せる条件(A = /tune )と見せない条件(B = /tune-no-reasoning ) の2条件を比較しています。 iter別 leaf F1 推移(T1 / train・test) iter A train A test B train B test 1(初期) 0.7916 0.7842 0.7834 0.8005 2 0.8368 0.8635 0.7906 0.7990 3 0.8403 ← A best 0.8641 ← A test best 0.8239 0.8494 ← B test best 4 0.8359 0.8052 0.8354 0.8124 5 0.8377 0.8346 0.8256 0.7803 6 0.8341 0.8318 0.8299 0.8472 7 0.8385 0.8081 0.8513 ← B best 0.7983( 低下 ) 8 0.8377 0.8338 0.8202 0.7523 9 0.8296 0.7720 0.8144 0.7896 10 0.8376 0.8467 0.8152 0.8137 所見 :trainではB(no-reasoning)のbest(0.8513, iter7)がA(0.8403, iter3)を +0.011上回りました。しかし、testではAが 0.8641と汎化した 一方、Bは 0.7983に落ち込みtrain過適合 が明らかになりました。画像情報に加えreasoningを参照することで、VLMの思考をより詳細に把握でき、汎化しやすいプロンプト探索を助けていると考えます。 人手チューニングとの関係 :人手側はGeminiのreasoningと画像を参照しながら、約30イテレーションの試行履歴とドメイン知見も活用できました。 情報量・試行回数の両面で優位だったにもかかわらず 、条件Aの自動ループが同等以上の水準(実験結果1)に到達したことは、reasoningを活用するループが人間の作業を代替しうることを示唆します。 実践から得た知見 エージェント設計は「自分がどう解いていたか」の移植から始める エージェント設計は、いきなりアーキテクチャを考えるのではなく、 自分がそのタスクをどう解いていたかを細分化し、そのままエージェントに乗せる のが効くことを学びました。また、最初からこの設計だったわけではなく、回しながら lessons.md や prompt-design-strategies.md を足していき徐々に今の形になりました。完璧な設計を最初から目指すのではなく、まず小さいPoCを回して改善していく方が結果的に早いと思います。 過学習とプロンプト肥大化に注意する 過学習は最も注意した点です。ループごとに定量的な精度が向上する一方で、 プロンプトは徐々に肥大化していきました 。近年のVLMは多少プロンプトが長くなっても推論能力が大きく下がらない印象ですが、学習データに寄った記述が増えるほど過学習のリスクは高まります。最低限 学習データ・テストデータを用意しておく ことをお勧めします。 コストに注意する ループごとに全画像へ推論を回すため、API課金が積み上がります。改善のたびにまず少量サンプルで実験し、有望なときだけ全量評価する仕様にすると、コストは削減できるはずです。ループの設計を誤ると 予期せずバックエンドで推論し続け、コストが跳ね上がる ので注意が必要です。 今後の展望 DSPy等の既存フレームワークとの比較 :本アプローチで十分な精度を得られたため今回は試しませんでしたが、DSPy等(GEPA等のオプティマイザを含む)と精度・効率を比較したい。 N数を増やして再現性を確認する :今回の結果はN=1の速報値のため、実行間のばらつきを評価できていない。複数回実行し、数値の信頼性を確かめたい。 社内ツール化・全社展開 :本アプローチを特定タスク専用の仕組みで終わらせず、誰でも自分のタスクに使えるよう汎用化して展開することを目指す。 まとめ 人手のプロンプトチューニングをClaude Codeのスキルで代替することで、人手チューニングと同等以上の性能(leaf F1でtrain +0.024、test +0.017)を発揮しました。工数面では全体評価サイクルを約3.5週間から約1週間に削減でき、スキーマの異なる他タスクへの適用可能性も確認しました。今回の取り組みを通じ、エージェント活用のあり方は「人間がプロンプトで指示をする」から「ループを回すためのエージェント設計」へ移りつつあると実感しました。本記事がループエンジニアリングに取り組む際の参考になれば幸いです。 おわりに ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com LLMの構造化出力エラーを削減する - ZOZO TECH BLOG ↩ Claude "Getting started with loops" ↩ Yuksekgonul et al. "TextGrad: Automatic 'Differentiation' via Text" ↩ Yang et al. "Large Language Models as Optimizers" (OPRO) ↩ Pryzant et al. "Automatic Prompt Optimization with 'Gradient Descent' and Beam Search" (APO) ↩ Google "Gemini API プロンプト設計戦略" ↩
はじめに こんにちは、データシステム部MLOpsブロックの 木村 と、推薦基盤ブロックの 上國料 です。 ZOZOでは2025年7月より、Claude Codeをはじめとする各種AI開発ツールを利用できる制度を開始しました。 corp.zozo.com 現在ではこの制度のもと、数百名にのぼる社員がClaude Codeを活用しています。 Claude Codeの活用が進むにつれ、スキルやエージェントといった開発資産がチームごとに蓄積されていきます。しかし、それらの資産がチーム内に閉じたままでは、組織全体で見たときに「AI開発が進むチーム」と「遅れるチーム」の差が広がる一方です。 この差を埋めるには、チームをまたいで開発資産を共有できる場が必要です。理想は、他チームの開発資産であっても、自チームの開発資産と同じようにいつでも参照・流用でき、それを起点に開発を始められる状態です。 本記事では、2025年下期から複数チームで運用してきた1つのClaude Codeプラグインマーケットプレイスについて紹介します。共同運用で直面した課題とその対策、そして得られた効果をまとめました。 本記事の内容は2026年8月時点の情報であることにご留意ください。 目次 はじめに 目次 背景 Claude Code プラグインマーケットプレイスとは 複数チームで運用するときに直面した課題 1. プラグインのグルーピングとメンテナンス責任が不明確になる 2. 共通ファイル(marketplace.json)の編集がコンフリクトし、登録漏れも起きる 3. 構造の誤りがレビューで見逃され、全チームに波及する 4. 目的に合致するスキルを検索で見つけられず、同じスキルが再発明される 5. 影響範囲が見えず、既存プラグインの修正が先送りされる 5つの課題への個別対策 1. チーム単位のディレクトリで責任範囲を分ける 2. 各プラグインのメタデータからmarketplace.jsonを自動生成する 3. 静的バリデーションで構造の誤りを検出する 4. やりたいことからスキルを逆引きできるようにする 5. @claudeの自動チェックで影響範囲への不安を減らす 複数チームで共有した効果 他チームの動きが見えるようになった 資産の横展開が生まれた 属人化が減り、誰でもすぐに始められるようになった 今後の展望 カタログの肥大化にどう向き合うか プラグインの追加・変更の周知コストをどう下げるか Claude Codeの進化の速さにどう追随するか 運用サイクルそのものを改善の対象にする まとめ 背景 Claude Code プラグインマーケットプレイスとは プラグインマーケットプレイス とは、Claude Code用の「プラグイン」を配布し、発見・バージョン管理・自動更新までまとめて行えるカタログのことです。 プラグイン自体は、以下のコンポーネントを1つのパッケージにまとめたものです。なお本記事では、CLIツールとしてのClaude Codeを指す場合は「Claude Code」、その基盤となるAIモデルを指す場合は「Claude」と表記します。 要素 役割 Skills Claudeが参照するガイドラインや、 /create-pr のようなスラッシュコマンドの実体 Agents 特定の作業を自律的にこなすサブエージェント Hooks 特定のイベント(タスク完了時など)で発火する処理 MCP Servers GitHubやNotionなど外部サービスと連携するためのサーバー設定 プラグインを使い始めるまでの手順は、 公式ドキュメント にもあるとおり、マーケットプレイスの追加と、そこからのプラグインの個別インストールという2段階に分かれています。追加した時点ではカタログを参照できるようになるだけで、プラグインはまだ1つもインストールされていません。 この2段階は、実際には次の2つのコマンドで実行します。 /plugin marketplace add <org>/cc-plugin-marketplace /plugin install my-plugin@cc-plugin-marketplace リポジトリ全体は次のような構成です。 リポジトリルート/ ├── .claude-plugin/ │ └── marketplace.json # カタログ全体の定義(全プラグインを列挙) └── my-plugin/ ├── .claude-plugin/ │ └── plugin.json # このプラグインのメタデータ └── skills/ └── ... マーケットプレイスの実体は、Gitリポジトリのルートに置く .claude-plugin/marketplace.json という1つのJSONファイルです。ここに配布する全プラグインの名前・リポジトリ内のパスなどを列挙します。 一方、各プラグインのディレクトリには .claude-plugin/plugin.json を置き、そのプラグイン自身のメタデータ(名前・バージョン・説明など)を記述します。つまり、プラグイン単位の情報はplugin.jsonに、それらを束ねるカタログ全体の情報はmarketplace.jsonに持つという2階層の構成です。marketplace.json側のpluginsエントリは、plugin.jsonの内容にsourceパスを加えたものです。 実際のファイルの中身は次のとおりです。 my-plugin/.claude-plugin/plugin.json : { " name ": " my-plugin ", " description ": " PR作成を支援するスキル集 ", " author ": { " name ": " Taro Yamada " } } ※ versionフィールドはあえて省略しています。省略するとClaude CodeはGitのcommit SHAをversionとして扱うため、マージがそのまま最新版の配信になります。 公式ドキュメント も、開発が活発な社内・チーム向けプラグインには、最もシンプルな構成としてこのパターンを紹介しています。 .claude-plugin/marketplace.json : { " name ": " cc-plugin-marketplace ", " plugins ": [ { " name ": " my-plugin ", " source ": " ./my-plugin ", " description ": " PR作成を支援するスキル集 " } ] } 複数チームで運用するときに直面した課題 もともと各チームは、それぞれ個別にスキルやエージェントを開発し、チーム内だけで使っていました。それを、前述の .claude-plugin/marketplace.json の仕組みを使って1つのプラグインマーケットプレイスにまとめ、複数チームで共同構築・運用する形に切り替えました。複数チームで1つのマーケットプレイスを共有するようになると、便利さの一方でいくつかの課題に直面しました。 私たちの場合、プラグインは開発からチームを越えて使われるまでに、次のような流れをたどっています。 複数チームが1つのマーケットプレイスを共有すると、この流れの各所で課題が起きやすくなります。いずれも参加チームとプラグインの数が増えるほど発生頻度が上がります。次節では、上記の流れの各段階に対応する5つの課題を順番に説明します。 1. プラグインのグルーピングとメンテナンス責任が不明確になる プラグインを「(1)開発する」段階で最初に直面するのが、マーケットプレイスのリポジトリ内でプラグインをどうグルーピングするかという問題です。マーケットプレイスには、PR作成支援のように全チームで使えるプラグインもあれば、パイプライン構築のように特定チームの業務に固有のプラグインもあります。これらを区別なく、次のようにフラットに並べると、利用者は自分に関係のないプラグインに紛れて目当てのものを見つけにくくなります。 リポジトリルート/ ├── .claude-plugin/ │ └── marketplace.json ├── plugin-a/ ├── plugin-b/ ├── plugin-c/ └── ... さらに深刻なのはメンテナンス責任の所在です。あるプラグインが壊れたとき、それを直す責任がどこにあるのかが、ディレクトリ構造上どこにも表現されていません。plugin.jsonのauthor情報をたどれば作者は判明しますが、そのプラグインがチーム内利用を想定したものか、全チーム向けの共通利用を想定したものかまでは読み取れません。その結果、壊れたまま直されない状態が続いたり、他チームのプラグインだと気付かずに手を入れてしまったりします。 2. 共通ファイル(marketplace.json)の編集がコンフリクトし、登録漏れも起きる 「(2)PRで反映する」段階でまず起きるのが、marketplace.jsonのコンフリクトです。前述の通り、Claude Codeの仕様でmarketplace.jsonには全チームの全プラグインの情報が1ファイルに列挙されています。そのため、Aチームが自分のプラグインを更新するPRも、Bチームが新しいプラグインを追加するPRも、最終的に同じファイルを編集することになります。マージのタイミングが重なればコンフリクトし、各チームが自律的に開発を進めるほどコンフリクトの頻度も上がります。 加えて、plugin.jsonとmarketplace.jsonは名前や説明といった情報を重複して持つため、手動で編集する場合は両方の内容を一致させ続ける必要があります。特に、新しいプラグインを追加した際にmarketplace.json側への登録を忘れると、そのプラグインは誰からも発見されません。 3. 構造の誤りがレビューで見逃され、全チームに波及する 続く「(3)検証する」段階では、設定ファイルの構造的な誤りを見逃してしまいます。誤りの種類はさまざまです。 plugin.jsonの必須フィールドの欠落 JSONの構文エラー プラグイン名の重複 スキル定義(SKILL.md)やagentのフロントマターの不備 hooks.jsonの構文エラー こうした誤りはdiffの見た目には現れにくく、レビュアーの目視チェックだけでは防ぎきれません。 壊れた設定が一度マージされると、そのマーケットプレイスを参照している全チームの全利用者に波及します。たとえばmarketplace.jsonがJSONとして壊れていると、 /plugin marketplace update がマーケットプレイス全体で失敗し、誰も新しいプラグインを取得できなくなります。「自分のプラグインしか触っていないのに、マーケットプレイス全体が更新できなくなった」という事故は、共有リポジトリならではのリスクです。 4. 目的に合致するスキルを検索で見つけられず、同じスキルが再発明される 「(4)見つけて導入する」段階では、そもそも目的に合致するスキルを見つけられないことが問題になります。1つのプラグインが複数のスキルを含むこともあるため、探す単位はプラグインではなくスキル単位になります。マーケットプレイスはもともと、スキルをさまざまなチームで参照できるように作ったものです。しかし運用が進むにつれてスキルの数そのものが増え続け、検索して見つけ出すコストが高くなっています。 見つけられないと、すでに同じものがあることに気付かないまま自分で作ることになり、別チームで同じスキルが再発明されます。 検索で見つけられない原因は、スキルの数だけではありません。SKILL.mdのdescriptionは開発者が実装や設計の観点で書くため、「PRのレビュー観点を多段階でチェックする」のような文言になりがちです。しかし利用者が検索するときの言葉は「レビューしたい」「PRを作りたい」のような、やりたいこと寄りの言葉です。descriptionをそのまま検索対象にしても、利用者の言葉とは一致せず、目的のスキルにたどり着けません。 5. 影響範囲が見えず、既存プラグインの修正が先送りされる 最後の「(5)改善する」段階では、既存のプラグインへの修正が先送りされがちです。最大の理由は、「自分の修正が他のスキルや呼び出し元を壊すのではないか」という不安です。たとえばSKILL.mdのdescriptionや引数の形式を変えると、それを前提に動いている別チームのスキルやワークフローが壊れかねません。マーケットプレイスを介して他チームがどう使っているかは見えないため、この不安を確かめる術がなく、些細な改善でも先送りされます。加えて、「これくらい満たしていれば導入してよい」という基準もないため、着手するかどうかの判断はそのつど個人の裁量に委ねられています。 5つの課題への個別対策 ここからは、それぞれの課題にどう対処したかを順番に紹介します。段階ごとに性質の異なる課題があるため、構造化・自動化・CIによる強制といった、異なるアプローチを組み合わせて対処しています。 1. チーム単位のディレクトリで責任範囲を分ける 以前はチームやプラグインの区別なく、1つのディレクトリにすべてのプラグインをフラットに並べていました。各プラグインの責任は、それを作ったチームが持つことにし、その責任をディレクトリ構造で表すという設計原則のもと、リポジトリをチーム単位のディレクトリに分けました。構成は次のようになっています。 ├── .claude-plugin/ │ ├── marketplace.json # 自動生成(直接編集禁止) │ └── marketplace-meta.json # マーケットプレイス自体のメタデータ ├── common-plugins/ # 全チーム共通のプラグイン │ └── plugin-a/ ├── xxx-plugins/ # xxxチーム所有 │ └── plugin-b/ ├── yyy-plugins/ # yyyチーム所有 └── zzz-plugins/ # zzzチーム所有 各チームがメンテナンスするプラグインは、そのチームのディレクトリ配下に置きます。この基準によって、プラグインの置き場所を見ればメンテナンス責任者が一意に決まります。common-plugins/への配置は、複数チームでの利用が見込まれるかを基準として、PRレビュー時に判断しています。 このようにチームごとに自由にディレクトリを分けられるのは、marketplace.jsonの各プラグインエントリが、プラグインの場所を相対パス(sourceフィールド)で持っているためです。実際のエントリは、次のようにチームごとに異なるディレクトリを指せます。 { " plugins ": [ { " name ": " plugin-a ", " source ": " ./common-plugins/plugin-a " } , { " name ": " plugin-b ", " source ": " ./xxx-plugins/plugin-b " } ] } プラグインをどこに置いてもmarketplace.json側でパスを合わせれば参照できるため、決まった1箇所に並べる必要がありません。ただし、この相対パスで参照できるのはmarketplace.jsonと同じリポジトリ内のプラグインだけです。別リポジトリのプラグインをGitHubソースなどで参照する方法もありますが、CIやリポジトリ設定をチームごとに分散させたくなかったため、今回は1つのリポジトリにまとめる構成を選びました。 もう1つの設計原則が、プラグインの粒度です。共通プラグインでは、関連性のない役割を1つのプラグインに詰め込まないというルールを設けています。一方、各チーム固有のプラグインは、どこまで細かく分けるかをチームの裁量に委ねています。たとえば、PRの作成・レビュー・管理のように互いに関連する役割は1つのプラグインにまとめます。一方、コードレビューを支援するスキルとインフラのコスト調査を支援するスキルのように、互いに無関係な役割は別々のプラグインに分割します。プラグインの数が増えても、自分が使わないプラグインのスキルが意図せず発火したり、そうしたスキルの説明文がコンテキストを圧迫したりする事態を避けられます。 2. 各プラグインのメタデータからmarketplace.jsonを自動生成する 各プラグインのplugin.jsonを正とし、marketplace.jsonは機械的に再生成する構成にしました。各プラグインのplugin.jsonを合成してmarketplace.jsonを生成するスクリプトを、CIから呼び出しています。開発者がやることは、自分のプラグインディレクトリの中でplugin.jsonを書くことだけです。 再生成はGitHub Actionsで自動化しています。PRを作成すると、ワークフローがplugin.jsonの変更を検知し、marketplace.jsonを再生成して、botコミットとしてPRブランチにpushします。 再生成スクリプトの要点を抜粋すると、次のとおりです。 #!/usr/bin/env bash set -euo pipefail META = " .claude-plugin/marketplace-meta.json " MARKETPLACE = " .claude-plugin/marketplace.json " # 各チームディレクトリ配下のplugin.jsonを、パス順にソートしてから収集する plugin_entries =$ ( find . -maxdepth 1 -type d -name ' *-plugins ' -print0 | xargs -0 -I {} find {} -path ' */.claude-plugin/plugin.json ' -print | sort | while IFS = read -r plugin_json ; do plugin_dir = " ${plugin_json % /.claude-plugin/plugin.json } " jq --arg source " $plugin_dir " ' . + {source: $source} ' " $plugin_json " done | jq -s ' . ' ) # marketplace-meta.jsonの内容に、収集したプラグイン一覧をマージする jq --argjson plugins " $plugin_entries " ' . + {plugins: $plugins} ' " $META " > " $MARKETPLACE " やっていることは、各チームディレクトリ配下のplugin.jsonをパス順に集めて、マーケットプレイス全体の情報と合成するだけの単純な処理です。 工夫の1つは、marketplace.jsonを部分的に更新せず、毎回すべて作り直す構成にしている点です。既存のplugin.jsonの集合から都度組み立て直すため、プラグインを削除したときの消し忘れが起きません。更新を重ねても、marketplace.jsonの内容が現状のplugin.jsonからずれていく心配もありません。この前提のもとでは、marketplace.json自体に直接手を入れることはできません。しかし名称のような、個々のプラグインには属さないマーケットプレイス全体の情報は、どのプラグインのplugin.jsonにも属さないため、生成元となる原本がありません。そこで、そうした情報だけをマーケットプレイスの構築時に marketplace-meta.json へ書いておきます。プラグインを追加するたびに書き換える必要はありません。 .claude-plugin/marketplace-meta.json : { " name ": " cc-plugin-marketplace ", " metadata ": { " description ": " 複数チームで共有するClaude Codeプラグインマーケットプレイス " } , " owner ": { " name ": " Claude Code Marketplace Maintainers ", " email ": " maintainers@example.com " } } スクリプトが収集したプラグイン一覧をこのファイルにマージし、marketplace.jsonを生成しています。 もう1つの工夫は、plugin.jsonの情報をパス順にソートしてから合成することで、副次的にコンフリクトを起きにくくしている点です。各プラグインのエントリはパス順の決まった位置に並ぶため、複数のPRがそれぞれ別のプラグインを追加しても、生成されるmarketplace.jsonの差分は配列内の別々の行に現れます。たとえば、 aaa-plugins/ チームのPRと zzz-plugins/ チームのPRが並行していても、前者は配列の先頭付近、後者は末尾付近にエントリを挿入するため、互いの差分は重なりません。末尾への追記のように複数のPRが同じ行を取り合うことがないため、マージ時にコンフリクトが起きにくくなっています。 3. 静的バリデーションで構造の誤りを検出する marketplace.jsonやplugin.jsonのように、目視でのレビューでは見逃しやすいファイルの構造的な誤りは、CIで自動チェックして検出しています。対象は次のとおりです。 JSON構文 必須フィールドの欠落 プラグイン名の重複 SKILL.mdやagentのフロントマターの不備 hooks.jsonの構文 こうした構造チェックは、CIのワークフローで実行しています。チェック自体は、Claude Code公式のCLIコマンド claude plugin validate にほぼすべて委譲しています。自作しているのは、公式コマンドがカバーしない次の2点だけです。 READMEを見ればプラグインの使い方がすぐわかるよう、README.mdの存在を確認する チームディレクトリにplugin.jsonはあるのに、marketplace.json側へ登録されていないプラグインがないかを検出する これらに加えて、 claude plugin validate はsourceフィールドの値がマーケットプレイスのルート外を指す ../ のようなパスになっていないかも検証します。プラグインはインストール時にキャッシュディレクトリへコピーされる仕様のため、ルート外を指すsourceは構文の誤りというより、動作しないか意図しない場所を参照してしまうリスクです。他の項目とは性質が異なるため、個別に触れました。 4. やりたいことからスキルを逆引きできるようにする スキルを「やりたいこと」から逆引きできる「スキルインデックス」を、ブラウザから確認できるようにしました。Claude Codeに直接探させることもできますが、Webページなら作業を始める前にさっと眺められますし、カテゴリタブを眺めているだけで「こんなスキルがあったのか」という偶然の発見もあります。 実際の画面は次のとおりです。検索窓にやりたいことを入力するほか、「やりたいこと」のカテゴリタブやチームでの絞り込みからも探せます。 上のスクリーンショットの時点で、対象となるスキルは73件にのぼります。これだけの数があると、利用者は目当てのスキルのdescriptionを覚えていられません。それでも、自分がやりたいことをそのまま打ち込むだけで目当てのスキルにたどり着けるのが、この仕組みの価値です。たとえば「PRのレビュー観点を多段階でチェックする」というdescriptionを知らなくても、「レビューしたい」と検索すればヒットします。検索する言葉が思いつかないときは、「Git・PR・レビュー」のようなカテゴリタブから探すほうが早いです。 この検索を支えている仕組みは、次の図のとおりです。 ポイントは次の2点です。 各スキルに、そのスキルが使われそうな場面を表す短いタスクフレーズ(想定作業)を複数持たせ、検索ではこのフレーズを最も重く評価する 想定作業フレーズとカテゴリは、日次のGitHub Actionsが生成する。SKILL.mdの内容に変更があったスキルだけを対象に、LLMを使わない通常のスクリプトとClaudeを組み合わせて更新する たとえばPR作成を支援するスキルには、次のようなassumed_work_ja(想定作業)とcategoryを持たせます。 { " id ": " common-plugins:create-pr ", " name ": " create-pr ", " assumed_work_ja ": [ " PRを作成する ", " 変更をpushしてレビュー依頼する ", " コミットしてPRを出す " ] , " category ": " git-pr " } 利用者が「PR出したい」で検索しても、descriptionの原文と一致していなくても、この想定作業フレーズ経由でヒットします。categoryは検索の照合には使わず、「Git・PR・レビュー」のような大まかな分野を表すラベルとして、画面上のタブ絞り込みに使います。 生成された想定作業フレーズは、検証・レビューを経てマージされると、GitHub Pagesとして自動で再公開されます。ホスティング先にGitHub Pagesを選んだのは、検索自体が埋め込みやLLM推論を使わないクライアントサイドのキーワードマッチングで完結しており、静的サイトのホスティングで十分だったためです。 5. @claudeの自動チェックで影響範囲への不安を減らす 静的バリデーションのCIは、設定ファイルの構造の正しさは保証します。しかし、スキルの中身の変更が既存の利用者や呼び出し元を壊さないかまでは検出できません。組織横断でスキルを使えるようにした分、開発者は自分のスキルが他チームでどう使われているかを把握できないため、入出力の形式を変えるような変更をしても、どこかで悪影響が出ていることに気付けません。そこで、GitHubのPRコメントで@claudeと打つだけで、この見えない影響範囲を機械的にチェックする仕組みを作成しました(ローカル実行も可能です)。チェックは2軸です。 ひとつは破壊的変更チェックです。検出対象は次のとおりです。 スキルのname・descriptionのトリガー条件・引数形式・出力契約の変更 agentの入出力契約やstatus codeの変更 hooksやMCP設定の変更 SKILL.mdから参照されるreferencesやscriptsの削除など、既存の利用者・呼び出し元を壊す変更 もうひとつはマーケットプレイス規約チェックです。記述面では、SKILL.mdの行数やdescriptionのフォーマットを確認します。Progressive Disclosure(本文を小さく保ち、詳細はreferencesなど別ファイルに逃がす設計)といった規約も対象です。安全面では、allowed-toolsの過剰付与、MCPのサービス分離、シークレットの直書きといった規約への準拠も確認します。 この仕組みには割り切りがあります。プラグインの良し悪しは文字列のレビューだけでは判断しきれず、結局は導入して動かしてみないとわかりません。そのためレビューで品質を保証しきることは目指さず、「最低限動くか」「規約に沿っているか」「既存のものと重複していないか」という入口の最低ラインだけを機械的に保証し、あとは導入して確かめる方針です。品質を最終的に担保しているのは、これまでどおり必須のPRレビューです。破壊的変更が起きていないことを自動で確認できれば、修正への心理的ハードルは下がります。チェック結果がGitHub上に投稿されるため、レビュアーは自動チェック済みであることを踏まえてダブルチェックでき、レビュー負担が減ります。運用件数が増えても、この負担軽減の効果は大きいと考えています。 現状は、ひとまず初期の導入として、上記の最小限の観点のみチェックできるように実装した段階です。それでも「壊れていないことは自動で分かる」という安心感は、日々の修正のしやすさに直結しています。実際、これまでのマージ済みPRのうち、既存プラグインの改善・修正を目的としたものは新規追加より多く、全体の6割近くを占めています。プラグインは作って終わりではなく、運用しながら継続的に手が入れられています。CIによる機械的な保証と、心理的なハードルを下げるコミュニケーション寄りの工夫、その中間に位置する仕組みだと捉えています。運用しながらリファクタし、リポジトリ独自のルールが固まってきたらチェック項目として組み込んでいく予定です。 複数チームで共有した効果 約10か月にわたり運用したところ、複数チームで1つのマーケットプレイスを共有した効果が見えてきました。 他チームの動きが見えるようになった 以前は、チーム内でどんなスキルやエージェントを作っているかが、チームの外からはわかりませんでした。スキルインデックスという共通の窓口ができたことで、他チームのスキルを見つけてSKILL.mdを読めるようになりました。たとえば、定例をAIで代替する取り組みを進めているチームがあります。そのチームのスキルの中身を読むと、どんなハーネスを使い、どんな工夫をしているのか、どこをAIに任せているのかがわかります。自分のチームに持ち込むとしたらどんな工夫を付け加えられそうか、そこまで考えられるようになりました。 資産の横展開が生まれた 以前は、それぞれのチームがAI活用の方法を1から考えている状態でした。workflowやloop、Routinesといった機能の使いこなし方も、チーム内の詳しい人に聞かなければわからず、知識が特定の人に偏っていました。マーケットプレイスを複数チームで管理するようになったことで、他チームの工夫を自分のチームの工夫として取り込みやすくなりました。その恩恵として、他チームが作った共通プラグインをそのまま使ったり、各チーム固有の工夫でも横展開しやすくなったりしています。 たとえば、GitHub・Confluence・Slack・Jiraを横断して過去事例を検索するスキルを、あるチームが作りました。このスキルは検索を担う4つのエージェントを独立したファイルへ切り出す構成にしていたため、別チームが障害調査を支援するスキルを作る際に、この4つの検索エージェントをそのまま呼び出して使えました。ゼロから検索の仕組みを作り直す必要はありませんでした。 また、定期実行するAIエージェント機能のRoutinesや、loop、GitHub Actionsのワークフローで呼び出すスキルも、このリポジトリに置いて共有する運用にしています。以前は、こうしたよくあるスキルをどのリポジトリで管理すべきか所在が定まっていませんでした。それが、誰かが音頭を取ったわけでもなく、マーケットプレイスに置けばよいという運用に自然と落ち着きました。 チームごとに開発資産を抱え込んだままだったら、これらの事例は生まれなかったはずです。共有の場があるからこそ、1チームの工夫が他チームの出発点になっています。 属人化が減り、誰でもすぐに始められるようになった 新しく入ったメンバーのオンボーディングも、マーケットプレイスによって変わりました。以前は、スキルがそれぞれのローカル環境に散らばっていたため、SKILL.mdの書き方やプラグインの構成をゼロから調べながら自分で作る必要があり、導入コストが高くなりがちでした。マーケットプレイスに既存のスキルがまとまっている今は、まずインストールして動きを確認するところから始められます。実際に動くスキルを読みながら使い方や設計の型を掴めるため、自分で一から設計を考える前に土台をつかめます。 今後の展望 これまでの運用を通じて、今後取り組むべき課題も見えてきました。 カタログの肥大化にどう向き合うか プラグインはplugin.jsonとディレクトリを用意するだけで簡単に作れます。この手軽さは開発を促進する一方、使われなくなったプラグインが増えても気づきにくくなります。使われなくなったプラグインも検索結果に混ざり続けるため、スキルインデックスの探しやすさを保つ効果も薄れていきます。カタログ全体を俯瞰して現状を把握するコストは、プラグイン数に比例して大きくなります。またあるプラグインが複数箇所から参照されるようになると、依存関係も見えにくくなります。 依存関係が見えないままでは、共通のプラグインへの変更がどのチームの利用に響くのかを事前に判断できません。@claudeによる破壊的変更チェックはその一歩ですが、プラグインの品質そのものを保証する仕組みではなく、「これを満たせば安心して変更してよい」と言えるレベルの動作確認の仕組みには至っていません。今後はプラグインの動作確認用のスキルを整備し、新規追加時・リファクタ時それぞれで基準を明文化することで、仮説検証を繰り返しやすい環境を作っていきたいと考えています。 プラグインの追加・変更の周知コストをどう下げるか チーム内で完結するプラグインなら、追加や変更があってもチームメンバーに直接伝えれば済みます。しかし、他チームでも使えるプラグインとなると、その存在に気づいてもらうこと自体にコストがかかります。プラグインの追加や更新を知らせる専用のSlackチャンネルは用意しているものの、投稿は個人の判断に委ねられているため、周知そのものが形骸化しつつあります。 ベースブランチへのマージをトリガーに、この専用チャンネルへ自動で通知を流す案を考えています。ただし、更新のたびに毎回発火させると通知が埋もれてしまい、かえって見られなくなる懸念があります。どの粒度・タイミングで知らせるのが適切か、今後詰めていきたいところです。 Claude Codeの進化の速さにどう追随するか Claude Code自体の機能追加は速く、数か月前のベストプラクティスが過去のものになることも珍しくありません。新機能が次々と追加される一方で、それに合わせてスキルやワークフローの書き方を更新し続けなければ、マーケットプレイスの資産はすぐに古びてしまいます。新しい機能が出るたびに、その機能をどう使えばよいか調べ、動く形にするまで試行錯誤する時間がボトルネックになりがちです。 この変化のペースに個々のチームがそれぞれキャッチアップするのは非効率です。新しい機能が出るたびに、スキルやloopをすぐ作って試せる仕組みを、マーケットプレイス側で用意しておきたいと考えています。雛形やスクリプト生成を支援することで、「使ってみる」までのハードルをさらに下げていく予定です。 運用サイクルそのものを改善の対象にする 最後は、運用の進め方そのものです。プラグインはplugin.jsonとディレクトリさえ用意すれば作れるため、プラグインを作る速度はこれからも上がり続けます。一方、それを複数チームで安全に運用し続けるための仕組みづくりは後回しになりがちだという感覚があります。本記事で紹介した5つの課題への対策も、最初から仕組み化されていたわけではなく、困りごとが顕在化してから後追いで整備してきたものです。前述のカタログの肥大化や周知コストの課題も、まだ「今後詰めていきたい」段階にとどまっており、同じ構図の繰り返しだと感じています。作る側の障壁を低く保ったまま、運用する側の障壁だけを先回りして整えるのは難しいというのが実感です。マーケットプレイスの運用に、整備して終わりという区切りはありません。課題を見つけるたびに仕組みで対処し、また新しい課題が出てくるというサイクルです。このサイクル自体をどう効率化するか、つまり運用サイクルそのものをエンジニアリングの対象にすることが次のテーマだと考えています。 本記事で紹介した工夫は、これまでの運用で得られた1つの到達点にすぎません。今後も運用しながら課題を見つけ、改善を重ねていきます。 まとめ 本記事では複数チームで1つのClaude Codeプラグインマーケットプレイスを育てる取り組みを紹介しました。 複数チームでの共同運用では、責任の所在の曖昧さやカタログの不整合などさまざまな課題に直面しましたが、多くは構造・自動化・CIの工夫を組み合わせることで仕組みとして解決できました。 結果として、marketplace.jsonを自動生成する構成にしたことで、コンフリクトや登録漏れが起きにくくなりました。チーム単位のディレクトリ構成で、プラグインのメンテナンス責任も明確になりました。構造の誤りも、CIが利用者へ届く前に検出できます。スキルインデックスによって目的のスキルも検索で見つけやすくなり、@claudeの自動チェックで既存プラグインの修正への心理的ハードルも下がりました。 運用開始前は、チーム間の変更のコンフリクトや壊れた設定が全チームに波及することを懸念していました。しかし2025年下期の運用開始から約10か月が経過し、大きなトラブルなく運用を続けられています。 本記事で紹介した知見が、複数チームでのマーケットプレイス運用を検討している方の参考になれば幸いです。技術仕様は Claude Codeの公式ドキュメント を参照して執筆しました。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、販促基盤ブロックの村井です。普段は販促基盤の開発を担当しています。 販促基盤は、ZOZOTOWNにおけるキャンペーン・割引・友だち紹介機能など販売促進の施策を横断的に支える共通基盤です。2025年に構築が始まり、初期スコープとして友だち紹介機能のリプレイスを行いました。友だち紹介機能は、既存会員が非会員を招待し、条件を満たすと双方にポイントを付与する機能です。本記事では、基盤の立ち上げ時に行ったO/Rマッパーの選定と、Auroraの読み書き分離でつまずいた話をご紹介します。 目次 はじめに 目次 背景・課題 O/RマッパーにDoma 3を選んだ理由 Domaでの実装パターン インフラ層のクラス構成 ドメインクラスで値オブジェクトを使用 Entityをテーブルの表現に徹させる Entityとドメインオブジェクトの変換 RepositoryImplでDBの例外を業務の例外へ変える 条件分岐をSQLテンプレートで書く 読み書きの振り分け 読み書きを分けた理由 1つ目の構成:判定の順序でつまずく 2つ目の構成:TransactionManagerを2つに分ける 調査:コネクションの取得回数を数える 3つ目の構成:DataSourceの参照先を揃える ThreadLocalを使う上での注意 別解:LazyConnectionDataSourceProxy まとめ 背景・課題 販促基盤には、エントリーや抽選といった複数種類の施策を1つの基盤で扱う構想を置いており、初期フェーズでは友だち紹介機能から着手しました。 友だち紹介機能を最初の対象に選んだ理由は2つあります。 1つは、既存の実装を流用できない状態だったことです。既存の友だち紹介機能は数年前に作った機能を暫定対応として運用しており、そのまま引き継げるソースコードがありませんでした。もう1つは機能不足です。ビジネス側が実施したい施策を、既存機能では実現できませんでした。そのため既存のソースコードとデータを引き継がず、新規開発として作り直す判断をしました。 この基盤では、立ち上げ時にJava 25とSpring Boot 3.5を土台にオニオンアーキテクチャを採用しました。ドメイン層を中心に置き、その外側にアプリケーション層、インフラ層、プレゼンテーション層を並べる構成です。層をまたぐ依存の向きはArchUnitのテストで検査しています。ドメイン層からインフラ層を参照するコードを書くと、このテストが失敗してビルドが通りません。 この方針を決めたあと、論点になったのがO/Rマッパーの選定です。求めたのは次の2点でした。 ドメイン層に永続化の都合を持ち込まない 発行されるSQLを自分たちで制御できる 1点目は、オニオンアーキテクチャを採用した時点で譲れない条件です。2点目は基盤の構想から来ています。扱う施策の種類が増えるほど、検索の条件は複雑になります。発行されるSQLの中身が見えないと、性能の問題が起きたときに原因を追えなくなります。 以下では、選定の過程と、採用したあとの実装の形を順に書きます。最後は、この構成で運用を始める前につまずいた話です。 O/RマッパーにDoma 3を選んだ理由 候補はSpring Data JPA、MyBatis、Domaの3つです。選定時に3候補それぞれの利点と欠点を洗い出しました。 候補 利点 欠点 Spring Data JPA Spring Bootとの親和性が高い。メソッド名からクエリを導出できる SQLを自分で書かないため、性能の問題が起きたときに原因を特定しにくい。関連データの取得方法を誤ると過取得やN+1を招く MyBatis SQLを自分で書ける。関連データのマッピングもできる XMLで書くと記述が冗長になる。XMLとアノテーションのどちらでも書けるため、書き方が割れると管理が複雑になる Doma SQLをファイルとして管理でき、そのままDBで実行して確認できる。SQLファイルの不足やDoma独自のコメント記法の誤りをビルド時に検出できる JPAのような暗黙的な関連取得や遅延ロードはない 最終的にDoma 3を採用しました。 決め手はSQLの扱いでした。DomaにはSQLテンプレートという仕組みがあります。公式ドキュメントで「two-way SQL」と呼ばれているもので、条件分岐やバインド変数をSQLのコメントとして書きます。SQLファイルをそのままDBのクライアントへ貼り付けて実行でき、書いたSQLとログに出るSQLもほぼ一致します。遅いクエリの原因を追いやすくなります。 また、クエリの自動生成に寄せたくない意図もありました。運用に入ったあと、どのようなSQLが出るかを把握できる状態を保ちたかったためです。Domaはこの点で扱いやすいと判断しました。 DomainConverterという仕組みによって値オブジェクトを永続化層まで持ち込める点も利点です。 Domaでの実装パターン 全体の層構成は次のとおりです。 層 役割 主な中身 ドメイン層 業務の言葉とルール 集約、値オブジェクト、Repositoryのインタフェース アプリケーション層 ユースケースの手続きとトランザクション境界 UseCaseのインタフェースとHandler インフラ層 技術による実現 Domaを使った永続化、DataSource、外部サービスとの連携 プレゼンテーション層 HTTPの受け口 Controller、リクエストとレスポンスのスキーマ 依存は内側へ向かう一方向です。ドメイン層は他のどの層も参照しません。Repositoryはインタフェースだけをドメイン層に置き、実装はインフラ層が持ちます。 ドメイン層には、紹介や報酬といった業務概念ごとにパッケージを分けて置いています。 本記事で扱うのは、このうちインフラ層です。 以降のコードには友だち紹介機能の概念が出てきます。各用語は以下のような意味です。 紹介者:招待した側 被紹介者:招待された側 紹介成立:紹介者と被紹介者が結びついた記録 報酬:付与するインセンティブ インフラ層のクラス構成 インフラ層は次のクラスで構成されます。 クラス 責務 DAO SQLファイルとの対応づけと、DB操作の宣言 Entity テーブル1行に対応するオブジェクト DomainConverter 値オブジェクトとDBの型の相互変換 RepositoryImpl Entityとドメインオブジェクトを組み立て、ドメインの操作として公開 DAO、Entity、DomainConverterはDomaが用意した仕組みです。RepositoryImplはこの基盤で用意したクラスです。 Entityはテーブルの構造をそのまま写した型です。Entityとドメインモデルを直接つなぐと、テーブルの変更がドメインモデルにも及びます。そこで両者の変換はRepositoryImplが行います。 ドメインクラスで値オブジェクトを使用 ドメインクラス は、カラムの値をJavaのオブジェクトとして扱うDomaの仕組みです。作り方は2つあります。 内部ドメインクラス:型そのものに @Domain を付け、対応するDBの型を valueType で指定する 外部ドメインクラス:型には手を入れず、 DomainConverter を実装したコンバータクラスに @ExternalDomain を付ける @Domain を選ぶと、ドメイン層の型にDomaのアノテーションが載ります。この基盤ではそれを避けたいので、外部ドメインクラスを選びました。コンバータクラスはインフラ層に置いています。次は紹介成立のIDを表す ReferralId のものです。 @ExternalDomain public class ReferralIdConverter implements DomainConverter<ReferralId, Long> { @Override @Nullable public Long fromDomainToValue( @Nullable ReferralId domain) { if (domain == null ) { return null ; } return domain.value(); } @Override @Nullable public ReferralId fromValueToDomain( @Nullable Long value) { if (value == null ) { return null ; } return ReferralId.of(value); // ReferralIdのファクトリメソッド } } 型引数がドメイン側とDB側の型の対応です。これを置くと、DAOの引数に値オブジェクトをそのまま使えます。 @Dao @ConfigAutowireable public interface ReferralDao { @Select List<ReferralEntity> selectReferrals( // 中略 @Nullable ReferrerId referrerId, @Nullable ReferredId referredId, SelectOptions options); @Insert (exclude = { "createdAt" , "updatedAt" }) Result<ReferralEntity> insert(ReferralEntity referral); @Select boolean existsById(ReferralId id); } DAOもDomaの仕組みです。 @Dao を付けたインタフェースの実装は、コンパイル時にアノテーションプロセッサが生成します。 @Select のメソッドは同名のSQLファイルと対応づき、 @Insert のようにSQLファイルを持たないものはDomaがSQLを組み立てます。 SelectOptions はページング、 Result は登録や更新の結果を受け取る型です。 外部ドメインクラスの方式では、値オブジェクト1つにつきコンバータクラスが1つ必要です。 Entityをテーブルの表現に徹させる Entityはテーブルまたはクエリの結果セットに対応するDomaの仕組みです。この基盤ではすべてrecordで書いています。以下は紹介成立を保持する referrals テーブルのものです。 @Entity (immutable = true , naming = NamingType.SNAKE_LOWER_CASE) @Table (name = "referrals" ) public record ReferralEntity( @Id @GeneratedValue (strategy = GenerationType.IDENTITY) @Nullable ReferralId id, // 中略 ReferrerId referrerId, ReferredId referredId, @Nullable LocalDateTime createdAt, @Nullable LocalDateTime updatedAt) {} referrerId や referredId などはDB上では BIGINT ですが、コード上は値オブジェクトとして読み書きできます。 id に付けた @GeneratedValue は、IDの採番をDB側の AUTO_INCREMENT に任せる指定です。登録するまでIDが決まらないため、 id だけ @Nullable にしています。 Entityとドメインオブジェクトの変換 ここからはDomaの仕組みではなく、この基盤の独自の話です。 ドメイン層の型は、IDの採番前と採番後で分けています。採番前は NewReferral のように、これから登録する内容だけを持ち、IDがありません。採番後は Referral でIDを持ちます。RepositoryImplは登録のときに採番前の型からEntityを組み立て、取得のときにEntityからドメインオブジェクトを復元します。 変換の中身は値の移し替えだけではありません。例えば報酬の実績を持つ rewards テーブルは、発行・付与・取消の日時を issued_at 、 granted_at 、 revoked_at の3列で持ちます。一方でドメイン側の Reward は RewardStatus という状態を持ちます。この状態に対応する列はなく、3つの日時から計算します。 private RewardStatus getStatus(RewardEntity entity) { if (entity.revokedAt() != null ) { return RewardStatus.REVOKED; } if (entity.grantedAt() != null ) { return RewardStatus.GRANTED; } return RewardStatus.ISSUED; } 状態の導出をドメイン層に置くと、テーブルの列構成がドメインへ漏れます。インフラ層に置くことで、それを防いでいます。 RepositoryImplでDBの例外を業務の例外へ変える referrals テーブルには複数の列の組に一意制約があり、各列に外部キー制約もあります。登録が失敗する理由はこの2つで、意味が違います。 なおDoma自体は UniqueConstraintException を投げます。これがSpringの DuplicateKeyException へ変わるのは、doma-spring-bootが例外を変換しているためです。 次は紹介成立を登録するRepositoryImplのメソッドです。 @Override public Referral create(NewReferral newReferral) throws ReferralAlreadyExistsException, ReferencedEntityNotExistsException { ReferralEntity entity = ... ; // NewReferral から Entity へ変換 try { Result<ReferralEntity> result = referralDao.insert(entity); return ... ; // Entity から Referral へ復元 } catch (DuplicateKeyException e) { throw new ReferralAlreadyExistsException( "Referral already exists: ..." , e); } catch (DataIntegrityViolationException e) { throw new ReferencedEntityNotExistsException( "Referenced entity does not exist: ..." , e); } } アプリケーション層はこの2つを別の例外へ変え、コントローラが409と400に振り分けます。 ただし DataIntegrityViolationException は DuplicateKeyException の親クラスで、整合性制約の違反を広く拾います。この基盤では外部キー制約の違反として扱っているため、他の制約に違反した場合も同じ例外になります。 条件分岐をSQLテンプレートで書く 紹介成立の検索は、紹介者や被紹介者を任意の組み合わせで絞り込めます。この分岐はSQLファイルに書きます。 SELECT id, -- 中略 referrer_id, referred_id, created_at, updated_at FROM referrals WHERE 1 = 1 -- 中略 /*%if referrerId != null */ AND referrer_id = /* referrerId */ 1 /*%end */ /*%if referredId != null */ AND referred_id = /* referredId */ 1 /*%end */ ORDER BY id コメントで書かれた命令をDomaはディレクティブと呼びます。 /*%if*/ と /*%end*/ が条件ディレクティブ、 /* referrerId */ がバインド変数ディレクティブです。 1 はコメントの外に置いたテスト用の値で、実行時にプレースホルダへ置き換わります。 この基盤では INSERT と UPDATE のSQLファイルを書かず、 @Insert と @Update の自動生成に任せています。 exclude で createdAt と updatedAt を外しており、登録時はMySQL側のデフォルト値で埋まります。SQLを手で書くのは、条件分岐や結合が必要な参照だけに絞る形です。 読み書きの振り分け ここからは実装後に判明した不備の話です。読み書きの振り分けを実装したつもりで、DataSourceのレベルでは切り替わっていませんでした。ユニットテストと結合テストは通っており、気付いたのは障害試験のときでした。 読み書きを分けた理由 データベースにはAurora MySQLを使っています。Auroraのクラスターは、書き込みを受け付けるWriterインスタンスと、読み取り専用のReaderインスタンスで構成されます。アプリケーションからはそれぞれのエンドポイントへ別に接続するため、接続プールも2つ用意しています。プールにはSpring Bootがデフォルトで使うHikariCPをそのまま採用しています。 参照をReaderへ寄せようとしたのは、以下の2つの傾向からでした。 1つはアクセスの傾向です。ユーザーの入り口が施策のLPであることが多く、公開期間の短いLPにはプッシュ通知やSNS配信による一時的なアクセス増が見込まれました。もう1つはデータの形です。キャンペーンやポイントのマスタを持つテーブルは読み取りの比率が高く、状態を追跡するテーブルでも読み取りのほうが多くなります。 この見込みに合わせて接続プールの上限を決め、Readerのほうを厚く取っています。 振り分けの入口はアプリケーション層に置いた TransactionService です。参照系のハンドラは readOnly 、更新系は required を呼びます。 @Override public SearchRewardsResult execute(SearchRewardsQuery query) throws SearchRewardsException { return transactionService.readOnly(() -> handle(query)); } 呼び出し側に経路の指定は出てきません。 readOnly と required のどちらを呼ぶかだけで、向かう先が決まる形を目指しました。 1つ目の構成:判定の順序でつまずく 最初は AbstractRoutingDataSource を1つ使いました。接続先を動的に選ぶためのSpringのクラスです。これを継承して determineCurrentLookupKey メソッドを実装すると、Springはコネクションを要求されるたびにそれを呼び、戻り値をキーとして接続先を引き当てます。キーとReaderやWriterの対応は、あらかじめ登録する仕組みです。 そこで、実行中のトランザクションが読み取り専用かどうかでキーを決めることにしました。この判定はSpringの TransactionSynchronizationManager.isCurrentTransactionReadOnly() で取得できます。 final var routingDataSource = new AbstractRoutingDataSource() { @Override protected Object determineCurrentLookupKey() { boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly(); return isReadOnly ? RouteFor.READER : RouteFor.WRITER; } }; しかし参照もWriterへ流れました。Springがトランザクションを開始する処理が2段になっているためです。前半でコネクションを取得し、後半で読み取り専用フラグをスレッドへ登録します 1 。接続先が決まるのは前半、フラグが立つのは後半です。キーを判定する時点ではフラグがまだ立っておらず、常にWriterが選ばれていました。 そこで判定のタイミングに依存しない構成としました。 2つ目の構成:TransactionManagerを2つに分ける 次に AbstractRoutingDataSource をやめ、ReaderとWriterそれぞれに TransactionManager を用意しました。 SpringTransactionService のコンストラクタで @Qualifier を使い、経路ごとのテンプレートを組み立てる形です。 public SpringTransactionService( @Qualifier ( "readerTransactionManager" ) PlatformTransactionManager readerTransactionManager, @Qualifier ( "writerTransactionManager" ) PlatformTransactionManager writerTransactionManager) { this .readOnlyTemplate = createTemplate(readerTransactionManager, template -> template.setReadOnly( true )); this .requiredTemplate = createTemplate(writerTransactionManager, _ -> {}); // 以下略 } 判定のタイミングの問題は消えました。ユニットテストと結合テストも通り、この構成でリリースの準備まで進みました。 この構成の不備は、障害試験中に判明しました。例外は出ておらず、ログにも警告はありません。SQLは正しく実行され、レスポンスも正常です。 不備の中身は2つありました。 1つは、行き先を切り替える仕組みが TransactionManager の選択しかなかったことです。 TransactionManager は生成時に1つのDataSourceと結びつきます。この構成ではReader用とWriter用を2つ用意し、 readOnly ならReader用、更新系ならWriter用を使う形にしていました。行き先が決まるのはこの使い分けだけで、DataSource自身は切り替える仕組みを持ちません。そのため TransactionManager を通らずにコネクションを取りに行く経路があると、そこは振り分けの対象外です。 もう1つは、参照系のトランザクションでコネクションを余分に取得していたことです。 調査:コネクションの取得回数を数える 使ったのはSpringのログです。 DataSourceUtils は、トランザクションに紐付いたコネクションを見つけられなかったときに Fetching JDBC Connection from DataSource を出力します。この行がトランザクション内で何回出るかを数えれば、余分な取得が起きているか分かります。 MySQLとRedisを起動し、アプリケーションのログをファイルへ出力しながら、全エンドポイントを順に呼び出します。呼び出しは調査用に書いた使い捨てのスクリプトで行いました。 docker compose up -d mysql redis ./gradlew bootRun > /tmp/bootrun_test_output.log 2 >&1 & 参照系のログは以下のようになっていました。 DataSourceTransactionManager - Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly DataSourceTransactionManager - Acquired Connection [HikariProxyConnection@425938879 ...] for JDBC transaction DataSourceTransactionManager - Switching JDBC Connection [...] to manual commit DataSourceUtils - Fetching JDBC Connection from DataSource DataSourceUtils - Fetching JDBC Connection from DataSource DataSourceTransactionManager - Initiating transaction commit DataSourceTransactionManager - Releasing JDBC Connection [HikariProxyConnection@425938879 ...] after transaction Fetching JDBC Connection from DataSource が2行並びます。更新系では1行でした。22のエンドポイントを実行したところ、参照系の10件が2行、更新系の11件が1行でした。残る1件はマスタの検索で、Redisのキャッシュに当たってDBへ行かないため0行です。 原因はDomaが受け取るDataSourceでした。このときReaderとWriterのHikariDataSourceを、それぞれ個別に TransactionAwareDataSourceProxy で包んでいました。トランザクションに紐付いたコネクションを返すプロキシで、これが2つある状態です。そしてDomaへ渡っていたのは、 @Primary が付いたWriter側のプロキシだけでした。 TransactionManager がコネクションを紐付ける相手と、Domaの問い合わせ先が食い違っていました。 3つ目の構成:DataSourceの参照先を揃える TransactionAwareDataSourceProxy を1つに統合し、その下に AbstractRoutingDataSource を置きました。 TransactionManager とDomaがどちらも同じ routingDataSource を起点にするため、コネクションを紐付ける側と参照する側が一致します。 @Bean public DataSource routingDataSource() { ReadWriteRoutingDataSource routingDS = new ReadWriteRoutingDataSource(); routingDS.setTargetDataSources( Map.of( ReadWriteRoutingDataSource.Route.READER, readerHikariDataSource(), ReadWriteRoutingDataSource.Route.WRITER, writerHikariDataSource())); routingDS.setDefaultTargetDataSource(writerHikariDataSource()); return routingDS; } @Bean @Primary public DataSource dataSource() { return new TransactionAwareDataSourceProxy(routingDataSource()); } @Bean public PlatformTransactionManager transactionManager() { return new DataSourceTransactionManager(routingDataSource()); } TransactionManager も1つに戻しました。経路はThreadLocalで持ちます。 public class ReadWriteRoutingDataSource extends AbstractRoutingDataSource { public enum Route { READER, WRITER } private static final ThreadLocal<Route> currentRoute = new ThreadLocal<>(); public static void setRoute(Route route) { currentRoute.set(route); } public static void clear() { currentRoute.remove(); } @Override protected Object determineCurrentLookupKey() { Route route = currentRoute.get(); return (route != null ) ? route : Route.WRITER; } } キーを設定するのは SpringTransactionService です。トランザクションを開始する前にThreadLocalへ書き込み、終わったら必ず消します。 private <T> T executeWithRoute( ReadWriteRoutingDataSource.Route route, TransactionTemplate template, Supplier<T> operation) { ReadWriteRoutingDataSource.setRoute(route); try { T result = template.execute(_ -> operation.get()); if (result == null ) { throw new IllegalStateException( "Transaction operation returned null" ); } return result; } finally { ReadWriteRoutingDataSource.clear(); } } template.execute より前にキーが決まるため、順序の問題も起きません。 修正後に同じスクリプトを実行すると、参照系の Fetching JDBC Connection from DataSource は2行から1行になりました。 ThreadLocalを使う上での注意 経路をThreadLocalに置いているため、別のスレッドへは伝わりません。現在この基盤は非同期処理を使っていないため問題になっていませんが、導入するときは経路の受け渡しを考える必要があります。 ThreadLocalが空のときはWriterへ倒す実装にしています。経路の設定を忘れた場合でも、読み取り専用の接続で更新を試みる事故は避けられます。 executeWithRoute は処理の終わりにThreadLocalを空にします。そのため readOnly から required を呼ぶような入れ子にすると、内側を抜けた時点で外側の経路まで消えます。この基盤ではこのような呼び出しをしていないため、問題にはなっていません。許す場合は、内側へ入る前の経路を退避し、抜けるときに書き戻す必要があります。 別解:LazyConnectionDataSourceProxy 判定の順序については、ルーティングDataSourceを LazyConnectionDataSourceProxy で包む方法もあります。物理的なコネクションの取得を最初のSQL実行まで遅らせるクラスで、キーの判定が読み取り専用フラグの設定より後になります 2 。 TransactionAwareDataSourceProxy のJavadocにも、中間プロキシとしてこのクラスへ委譲できると書かれています 3 。 この場合、読み取り専用フラグをそのままキーに使えます。ThreadLocalを自分で管理する必要がなくなるため、前節で挙げた注意も不要になります。現在の構成で問題なく動いているため移行はしていませんが、同じものをこれから組むなら検討の余地があると考えます。 Spring Framework 6.1.2以降は setReadOnlyDataSource も使えます。読み取り専用のDataSourceを直接指定でき、 AbstractRoutingDataSource を書かずに済みます。 いずれの構成でも、 TransactionAwareDataSourceProxy はプロキシの最も外側に置きます。そのうえで TransactionManager とDomaが同じDataSourceを見るようにします。 まとめ 販促基盤のO/Rマッパーには、発行されるSQLを自分たちで追えることを重視してDoma 3を選びました。外部ドメインクラスを使ったので、ドメイン層をDomaから切り離したまま値オブジェクトを永続化層で用いることができました。RepositoryImplではDBの制約違反をドメインの例外へ変えており、一意制約の違反と外部キー制約の違反でAPIのエラーを振り分けられます。 読み書きの振り分けでつまずいた原因は、 TransactionManager とDomaが別のDataSourceを見ていたことでした。この状態では、トランザクションに紐付いたコネクションを再利用できずに取り直します。両者が同じDataSourceを起点にする形へ直して解決しました。動作確認には Fetching JDBC Connection from DataSource のログ行数を数えました。これがいちばん手軽でした。 JavaでDDDを実践する際のO/Rマッパー選定の参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com 前半は DataSourceTransactionManager の doBegin メソッドです。後半は AbstractPlatformTransactionManager の prepareSynchronization メソッドです。この中で setCurrentTransactionReadOnly メソッドが呼ばれます。 ↩ Document LazyConnectionDataSourceProxy setup for routing datasource to act on transaction definition read-only flag ↩ TransactionAwareDataSourceProxy (Spring Framework API) 。「should be the outermost DataSource of a chain of DataSource proxies/adapters」と記載があります。 ↩