TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

982

はじめに こんにちは、SREブロックの岩切です。普段はZOZOTOWN Yahoo!店の連携基盤のリプレイスを担当しています。 ZOZOTOWN Yahoo!店では、FTPによるデータ連携の遅延をSplunkアラートで検知し、PagerDutyにインシデントを作成して運用しています。しかし、遅延が解消してもインシデントは自動でResolveされず、手動で対応する必要がありました。 Splunk × PagerDutyの運用では、「アラートは自動だがResolveは手動」という課題に悩まされがちです。本記事では、 追加のミドルウェアなしでインシデントを自動Resolveする実装パターン を紹介します。 目次 はじめに 目次 この記事で得られる知見 背景・課題 自動Resolveの要件整理 解決策の検討 アプローチ1:Splunk Add-onのparam.dedup_keyを使う アプローチ2:Events API v2統合を使う アプローチ3:Event Transformer統合 + Service Event Orchestration(採用) 全体のアーキテクチャ インシデント作成(遅延発生時) インシデント自動Resolve(遅延解消時) 設定の詳細 Splunk側の設定 トリガーアラート(既存設定の変更) 解消アラート(新規作成) PagerDuty側の設定 Event Transformer統合(新規作成) Service Event Orchestration 動作シナリオ 正常時(遅延なし) 遅延発生から解消までの流れ まとめ この記事で得られる知見 Splunk Add-on for PagerDutyの制約と、その回避方法 PagerDutyの dedup_key を活用したインシデントのライフサイクル管理 「検索結果が0件のときだけアラートを発火する」SPLテクニック 背景・課題 ZOZOTOWN Yahoo!店では、商品情報などのデータをFTPで連携しています。FTPによるデータ反映が一定時間遅れると、Splunkのアラートが発火し、PagerDutyのインシデントが作成されます。 しかし、遅延が解消してもインシデントは自動Resolveされず、 毎回オンコール担当者が手動でResolve していました。この手動対応が繰り返され、運用上の負担になっていました。 手動Resolveの課題は以下のとおりです。 対応コスト :遅延解消を確認し、PagerDutyを開いてResolveする作業が都度発生する Resolve忘れのリスク :インシデントが残り続けると、新たなアラートとの区別がつきにくくなる オンコール負荷 :深夜・休日に遅延が解消しても、Resolveのためだけに対応が必要になる場合がある 自動Resolveの要件整理 PagerDutyのインシデントを自動Resolveするには、以下の2つを満たす必要があります。 インシデント作成時と 同じ dedup_key で resolve イベントを送ること イベントの event_action が resolve であること dedup_key とは、PagerDutyがイベントをインシデントに紐づけるための一意キーです。同じ dedup_key を持つイベントは同一インシデントとして扱われ、重複排除やResolveの対象になります。 要件自体はシンプルに見えますが、Splunk Add-on for PagerDutyには resolve イベントを直接送信する機能がありません。そのため、Splunk側とPagerDuty側の両方に工夫が必要でした。 解決策の検討 最終的な設計に至るまで、いくつかのアプローチを検討・検証しました。 各アプローチの説明へ入る前に、本記事で登場するPagerDutyの主要な概念を整理します。 Event Transformer統合 :Splunkなどの外部ツールからイベントを受け取り、PagerDuty形式に変換する統合タイプ。 incident_key の設定により、受信したイベントのどのフィールドを dedup_key として使うかを決定する Service Event Orchestration :サービスに届いたイベントをルールベースで加工する機能。条件に応じて event_action の変更、優先度の設定などが可能 アプローチ1:Splunk Add-onの param.dedup_key を使う Splunk Add-onの param.dedup_key パラメータで dedup_key を明示的に指定する方法です。しかし、Event Transformer統合は incident_key 設定に基づいて dedup_key を自動生成するため、 Splunk側の param.dedup_key は無視されます 。この方法では意図した dedup_key を指定できず、採用を見送りました。 アプローチ2:Events API v2統合を使う Events API v2統合であれば、ペイロードの dedup_key をそのまま使えます。しかし、Splunk Add-onはSplunk固有のペイロード形式で送信するため、 Events API v2統合ではペイロードを解釈できず、インシデントが作成されません でした。 アプローチ3:Event Transformer統合 + Service Event Orchestration(採用) 新しいEvent Transformer統合を作成し、 incident_key=source に設定します。SPLに eval source="yshp-ftp-delay-warning" を追加することで、トリガーと解消で同じ dedup_key を生成します。そのうえで、Service Event Orchestrationで resolve に変換します。 検討した3つのアプローチの比較 全体のアーキテクチャ 自動Resolveの仕組みは、 インシデント作成 と インシデント自動Resolve の2つのフローで構成されます。設計のポイントは以下の2点です。 dedup_keyをSPL側で強制的に統一する : eval source="yshp-ftp-delay-warning" でトリガーと解消に同じ値を付与 resolveはPagerDuty側で変換する :Splunkからは trigger として送り、Orchestrationで resolve に変換 自動Resolveの全体アーキテクチャ インシデント作成(遅延発生時) Splunk :「Yahoo!FTPデータ反映遅延警告」アラートが遅延ファイルを検出して発火 SPL :末尾の eval source="yshp-ftp-delay-warning" により結果にsourceフィールドを付与 Event Transformer : incident_key=source の設定により dedup_key="yshp-ftp-delay-warning" を生成 Service Event Orchestration : event.summary に「解消」を含まないため、そのまま通過(trigger)。なお、Splunkの search_name (アラート名)はPagerDutyでは event.summary として受信される 結果 :インシデント作成(同一 dedup_key なら重複排除) インシデント自動Resolve(遅延解消時) Splunk :「Yahoo!FTP遅延解消チェック」アラートが遅延ファイル0件を検出して発火 SPL : | stats count | where count = 0 | eval source="yshp-ftp-delay-warning" で遅延ファイルが0件のときだけ結果を返す Event Transformer : dedup_key="yshp-ftp-delay-warning" (トリガーと同一キー) Service Event Orchestration : event.summary に「解消」を含むため、 event_action を resolve に変換 結果 :同一 dedup_key のインシデントを自動Resolve 以上の仕組みで解決した技術的課題をまとめます。 課題 解決方法 Splunk Add-onは resolve を送れない Service Event Orchestrationで trigger → resolve に変換 trigger/resolveのインシデント紐づけ SPLに eval source="yshp-ftp-delay-warning" を追加し、Event Transformerの incident_key=source で同一 dedup_key を生成 解消イベントの識別 event.summary に「解消」を含めてOrchestrationルールで判別 既存統合の incident_key=search_name では dedup_key 不一致 FTP遅延専用のEvent Transformer統合を新規作成し incident_key=source に設定 設定の詳細 Splunk側の設定 トリガーアラート(既存設定の変更) 「Yahoo!FTPデータ反映遅延警告」アラートに以下の変更を加えました。 integration_key / url を新しいEvent Transformer統合に変更 SPL末尾に | eval source="yshp-ftp-delay-warning" を追加 発火条件・スケジュールは変更なし 解消アラート(新規作成) 「Yahoo!FTP遅延解消チェック」アラートを新規作成しました。 解消アラートの設定画面 設定項目 値 備考 SPLクエリ トリガーと同一ベース + | stats count | where count = 0 | eval source="yshp-ftp-delay-warning" 遅延ファイル0件のときのみ結果を返す counttype number of events SPL結果の行数で判定 quantity / relation 0 / greater than result_count > 0で発火 cron_schedule 02-59/10 * * * * 10分毎(トリガーと同一間隔) ここでのポイントは、SPLに追加した | stats count | where count = 0 の組み合わせです。 通常、Splunkアラートは「検索結果が存在するとき」に発火します。しかし今回実現したいのは「遅延ファイルが0件のとき(=遅延が解消したとき)」の発火です。遅延ファイルが0件だと検索結果も0件になり、アラートが発火しません。 そこで、ベースとなるSPLの後に | stats count | where count = 0 を追加します。 stats count は検索結果の件数を常に1行で返すため、遅延ファイルが0件なら count=0 の1行が出力され、 where count = 0 を通過します。逆に遅延ファイルが存在する場合は count > 0 となり、 where count = 0 で除外されて結果が0行になります。 これにより、「結果が0件のときだけ発火する」という逆転の発火条件をSPLだけで実現しています。 PagerDuty側の設定 Event Transformer統合(新規作成) FTP遅延のトリガー・解消アラート専用に、新しいEvent Transformer統合を作成しました。 項目 値 統合名 Splunk (自動Resolve用) 対象サービス zozo-yshp-alert incident_key source Event Transformer統合の設定画面。incident_keyをsourceに設定 既存のEvent Transformer統合は incident_key=search_name に設定されており、アラート名がそのまま dedup_key になります。トリガーと解消でアラート名が異なるため、既存統合では dedup_key が一致せずResolveできません。そこで incident_key=source に設定した専用統合を新規作成し、SPLで付与した source フィールドを共通の dedup_key として使用します。 Service Event Orchestration 項目 値 ルール event.summary matches part '解消' → event_action = resolve catch_all そのまま通過(変換なし) Service Event Orchestrationのルール設定 Service Event Orchestrationは対象サービスのイベントのみに適用されます。他のアラートは従来のEvent Transformer統合を使用しており、影響はありません。 動作シナリオ 正常時(遅延なし) 解消アラートが10分毎に発火し、PagerDutyに resolve イベントが送信されます。これは設計上意図した動作です。PagerDutyは、対応する dedup_key のインシデントが存在しない場合、 resolve イベントを無視します。新規インシデントが作成されることはないため、副作用はありません。 遅延発生から解消までの流れ 実際の動作を時系列で示します。遅延が発生するとインシデントが作成され、解消後に最初の解消チェックが走ったタイミングで自動Resolveされます。 遅延発生から自動Resolveまでの時系列 まとめ Splunk Add-on for PagerDutyには resolve を直接送れない制約があります。今回はこの制約を、 Event Transformer統合の incident_key 設定 と Service Event Orchestration の組み合わせで解決しました。 この仕組みの導入により、以下の改善が得られました。 Slackに通知された自動Resolveの実績 オンコール担当者の手動Resolve作業がなくなった インシデントのライフサイクルが実際の障害状況と一致するようになった Splunk Add-onの制約内で、追加のミドルウェアなしに実現できた Splunk × PagerDutyの運用では、同様の「アラートは自動だがResolveは手動」という課題を抱えているケースがあるかもしれません。本記事の設計パターンが参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくださる方を募集中です。ご興味のある方は、ぜひ採用ページをご覧ください。 corp.zozo.com
はじめに こんにちは、ZOZOTOWN企画開発部 企画フロントエンド2ブロックのパクサンイです。普段はZOZOTOWNにあるCMSベースのLPページのメンテナンスや機能追加、企画LPページ環境のメンテナンスを担当しています。 ZOZOTOWNの複数のWebアプリケーション間で、プロモーション用ランディングページコンポーネントを共有するために、 Lit ベースのWeb Componentsを導入しました。本記事ではその事例を紹介します。 ZOZOTOWNでは多数のLPページが開設・更新されており、従来はiframeを使った埋め込み方式でUIを共有していました。しかし、この方式にはさまざまな課題が存在し、レガシー環境からNext.jsベースの新環境へのリプレイスを進める中で、フレームワークに依存しないUI共有アーキテクチャが必要となりました。 本記事では、iframeベースの共有方式が抱える具体的な課題と、LitベースのWeb Componentsを採用した理由と選定プロセスを解説します。さらに、フレームワーク非依存なコンポーネント共有基盤を設計・実装する中で得た経験を共有します。 対象読者 マルチWebアプリケーション環境でUI共有に課題を感じているフロントエンドエンジニア iframeを使ったUI共有方式の代替手段を探している方 Web Componentsの導入を検討している方 目次 はじめに 対象読者 目次 背景・課題 ZOZOTOWNフロントエンドのマルチWebアプリケーション構成 LPコンポーネントの共有仕様 従来のiframeベース共有方式とその課題 1. レイアウト制御の煩雑さ 2. UI制御の複雑化 3. SEOの制約 アプローチ:Web Componentsの導入 要件整理 技術選定:Lit基盤Web Components Litを選択した理由 npmパッケージ方式を除外した理由 設計・実装 全体アーキテクチャ 1. 利用側アプリケーションによるデータ取得・加工 2. Lit ContextによるProps Drilling防止 3. Scriptローディングによる独立したUI更新 4. Shadow DOMからLight DOMへの切り替え ビルド・配信 全体フロー LPコンポーネント開発側(コンテンツ共有専用リポジトリ) 利用側Webアプリケーション 効果 学んだこと 今後の課題 今後の展望 まとめ 最後に 参考資料 背景・課題 ZOZOTOWNフロントエンドのマルチWebアプリケーション構成 現在、ZOZOTOWNのフロントエンドは3つのマルチWebアプリケーションで運用されています。 リポジトリ 説明 主な役割 リポジトリA(レガシー環境) 統合リポジトリ 既存の全ページを管理 リポジトリB(リプレイス環境) コアメインページ ホーム、カート、検索結果、商品詳細ページなど リポジトリC(リプレイス環境) 企画ページ フルスクラッチLP、CMS活用LP レガシー環境では複数のサービスが単一リポジトリで管理されていたため、共通UI共有に関する課題はありませんでした。しかし、リプレイス後にマルチWebアプリケーションが増えたことで、従来の方式ではUIを再利用できなくなりました。 LPコンポーネントの共有仕様 ZOZOTOWNでは特定のLPコンポーネントを複数のページで表示しています。一部のページでは以下の2つの形態で表示されます。 単独ランディングページ — header/footerを含むフルページ モーダル表示 — 特定ページのバナークリック時に、header/footerなしでコンテンツセクションのみをモーダルで表示 つまり、ほぼ同一のUIでありながら、header/footerの有無、SEOメタタグ、計測用トラッキングスクリプトの有無などで差異がある仕様でした。 従来のiframeベース共有方式とその課題 リプレイス後は以下の方式でUIを共有していました。 環境 運用方式 リポジトリA(レガシー) LPページ配信 + iframe用LPページ(header/footerなし)配信 リポジトリB・C(リプレイス) 特定ページにバナー表示 → クリック時にモーダル内でiframeとしてリポジトリAのLPを埋め込み このiframe方式には以下の課題が存在していました。 1. レイアウト制御の煩雑さ iframeは独立したドキュメントを読み込むため、フレームサイズの調整や使用箇所ごとの非表示領域の処理は対応していたものの、煩雑な部分がありました。 2. UI制御の複雑化 各バリエーションに応じて非表示にすべき子コンポーネントもあり、クエリパラメータや postMessage で解決できるものの、ケースが増えるほど複雑化しました。 3. SEOの制約 検索エンジンはiframe内のコンテンツを src 側の所有として認識するため、SEO上の制約がありました。 アプローチ:Web Componentsの導入 要件整理 上記の課題を解決するために、以下の4つの要件を整理しました。 要件 説明 各アプリのデプロイなしにUI更新 iframe方式の利点であった各マルチWebアプリケーションのデプロイなしにUI変更が反映されることを維持 iframe脱却 各アプリケーションでネイティブにUIをレンダリング フレームワーク非依存 React、Vueなど、どのフレームワークでも使用可能であること 軽量バンドルサイズ 利用側に負担のない最小限のサイズを維持 技術選定:Lit基盤Web Components Web Componentsはブラウザのネイティブコンポーネントモデルであり、特定のフレームワーク(React、Vueなど)に依存せず、ブラウザが直接理解する標準技術です。主に以下の3つの中核技術で構成されています。 Custom Elements :開発者が独自のHTMLタグを定義できる。タグ名にはハイフン( - )を含む規約がある。 Shadow DOM :コンポーネントのスタイルとマークアップを外部ページから隔離(Encapsulation)する。 HTML Templates : <template> と <slot> 要素により、再利用可能なマークアップ構造を定義する。 このWeb Componentsをより効率的に開発するため、 Lit ライブラリを採用しました。 Litを選択した理由 選定基準 Litの特徴 バンドルサイズ 約5KB(minified + compressed)で非常に軽量 リアクティブプロパティ Reactive Propertiesにより状態変更時に自動再レンダリング テンプレート Tagged Template Literalsベースで別途コンパイル不要 パフォーマンス Virtual DOM diffingなしに動的部分のみを直接更新 相互運用性 すべてのLitコンポーネントはネイティブWeb Componentであり、HTMLを使うあらゆる場所で動作 npmパッケージ方式を除外した理由 LPページはテキスト更新の頻度が高く、UIも不定期に変更されます。npmパッケージで運用すると、変更のたびに各環境でパッケージ更新+デプロイが必要となり、運用負荷が大きいため除外しました。 設計・実装 全体アーキテクチャ コンテンツ共有専用リポジトリを新たに構築し、以下の設計原則を適用しました。 1. 利用側アプリケーションによるデータ取得・加工 ZOZOTOWNにはページアクセス時に初期設定すべき値やAPIフェッチのためのロジックが各アプリケーションに存在します。これらのロジックをコンテンツ共有専用リポジトリにも含めると管理が二重になりメンテナンス負荷も大きくなるため、このリポジトリでは UIレンダリングのみ を責任範囲としました。 利用側の親アプリケーションでデータを取得・加工してpropsで渡す形式を採用しています。 2. Lit ContextによるProps Drilling防止 UI内部で必須的に共有すべき情報(デバイス種別、性別など)は、 Lit Context を活用したカスタム要素を設けて処理しました。 Lit ContextはReactのContext APIと同様の概念で、Props Drillingなしに上位から下位コンポーネントへデータを渡すことができます。 3. Scriptローディングによる独立したUI更新 各Webアプリケーションで別途デプロイなしにUI変更が可能なよう、 Scriptローディング を採用しました。各アプリケーションでは <script> タグで必要なコンポーネントのJSファイルを読み込み、クライアントでWeb Componentがレンダリングされます。 4. Shadow DOMからLight DOMへの切り替え Web Componentsの代表的な特徴であるShadow DOMは、スタイルを完全に隔離し、コンポーネント内部のCSSが外部に影響せず、外部CSSも内部に影響しません。 しかし、今回のケースでは、Shadow DOMで隔離して管理するUIではなく、利用側から自由にスタイルだけでなく要素にもアクセスできることが重要でした。そのため、Shadow DOMの代わりに Light DOM を採用しました。 ビルド・配信 Viteを使用してLit基盤Web Componentをビルドし、S3にデプロイしてCDN経由で配信します。 全体フロー LPコンポーネント開発側(コンテンツ共有専用リポジトリ) Lit + Vite dev serverでローカル開発 各テスト環境にてHTML + JSで動作確認 問題なければ各環境(S3)にデプロイして確認 利用側Webアプリケーション SSR時にCMS APIでデータ取得(スケジュールに応じて変更されるテキストなどはCMSで管理) クライアントで <script> タグによるJSファイルローディング、Web Componentのレンダリング カスタムタグへCMS API仕様に合わせたデータをpropsで渡す 効果 この仕組みの導入により、以下の効果が得られました。 マルチWebアプリケーション間でiframeを使わずにUIコンポーネントを共有できるようになった 各アプリケーション側のリリース(デプロイ)なしでコンテンツ更新が可能になった 利用側からスタイルだけでなく要素へのアクセスも自由に可能になった(Light DOM採用) CMS連携により、エンジニア以外でも直接スケジュールベースのデータ管理が可能に 学んだこと Litを通じて開発する中で、Web Componentsのベースとなるウェブ標準技術をより深く理解し、関心を持つようになりました。また、CSS変数などを活用してJavaScriptなしにCSSだけでスタイルを制御する方法も知ることができました。 今後の課題 Web Components公式のSSR対応はまだ限定的ですが、Lit SSRなど複数の解決策がライブラリやコミュニティで共有されています。現在、このプロジェクトで管理しているLPページの仕様ではWeb ComponentのSSRは不要ですが、将来に備えた準備は必要だと考えています。 また、現在の運用方式では、Scriptローディング+CMSデータ連携という構造上、テストが非常に重要であり補強が必要です。 今後の展望 移行すべきLPページが多数残っており、段階的にマイグレーションを進めていく予定です。より小さな単位の共用コンポーネントもこの基盤で管理できるよう拡張を検討しています。また、可能であれば、ネイティブアプリケーションでの活用も検討したいと考えています。 まとめ 本記事では、ZOZOTOWNのマルチWebアプリケーション環境におけるiframeベースUI共有方式の課題を解説しました。また、LitベースのWeb Componentsを活用したフレームワーク非依存のコンテンツ共有基盤の構築事例を紹介しました。 ReactベースであればReactでもUIを共有する方法はあります。しかし、今後どのフレームワークでも問題なく移植できるWeb Componentsを選択し、メインスタックと共存しながら運用するのもよいのではないでしょうか。同様の課題をお持ちの方の参考になれば幸いです。 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com 参考資料 MDN - Web Components Lit 公式ドキュメント Lit Context MDN - Using Shadow DOM MDN - iframe Vite - Building for Production
はじめに こんにちは、FAANS部フロントエンドブロックの 中島 です。普段はFAANSのiOSアプリ開発を担当しています。FAANS iOSチームではSwift 6移行の取り組みをしています。以前、 Strict Concurrency CheckingをTargeted に変更した過程で得た知見を紹介しました。今回TargetedからCompleteに変更するとXcodeで約1400個の新たな警告が出ました。機械的に対応できる警告もありますが、曖昧な知識だと修正が難しいケースもありました。本記事では、Swift 6移行時の警告やエラー解決を通じて得た知見を共有します。実際に遭遇した警告への対処法など、移行作業を始める前に押さえておきたかったポイントを中心に解説します。 移行当初はXcode 16.4だったので、最新のXcodeでは警告がエラーとなる可能性もありますが、本記事では警告で統一します。また、Swift 6でビルドをするとそれまで警告だったものがエラーになりビルド自体が通らなくなるため、まずはSwift 5の段階ですべての警告を解消しました。その後、Swift 6へ切り替えてビルドが通ることを確認し、新たに不具合が発生していないかを検証しました。 目次 はじめに 目次 Completeに変更後、新しく発生した警告の分類 Sendable非準拠の警告原因 解決方法 コンテキストの不一致について 1. nonisolatedのコンテキストでMainActorを利用するケース 解決方法 2. クロージャ内がnonisolatedになるケース Sendableクロージャにおけるケースの実例 解決方法1. Task { @MainActor in } の利用 解決方法2. async関数の利用 3. nonisolatedなプロトコルをMainActorで実装するケース 自作プロトコルのケース ライブラリのプロトコルのケース 実行時にクラッシュするケースの紹介 クラッシュの原因 Combineでコンテキスト不一致によるクラッシュのケース sendingキーワードについて Region Based Isolationについて Task-isolatedな値について Combineのsinkする値について その他の警告 1. passing closure as a 'sending' parameter risks causing data races 2. static property '...' is not concurrency-safe because it is nonisolated global shared mutable state まとめ さいごに Completeに変更後、新しく発生した警告の分類 Strict Concurrency CheckingをTargetedからCompleteへ変更後、新たに発生した警告を分類して集計しました。Xcode 16.4でMinimum Deployment TargetをiOS 16、Swift Language VersionをSwift 5に設定してビルドした際に発生した警告です。 警告概要 警告例 分類 割合 Sendable非準拠 type '...' does not conform to the 'Sendable' protocol Sendable 約46% nonisolatedで MainActorを利用 call to main actor-isolated initializer '...' in a synchronous nonisolated context コンテキスト不一致 約44% SendableクロージャでMainActor を利用 main actor-isolated property '...' can not be referenced from a Sendable closure コンテキスト不一致 約2% SendableクロージャでNonSendable を利用 capture of '...' with non-sendable type '...' in a @Sendable closure Sendable / コンテキスト不一致 約2% nonisolatedプロトコル要件の不一致 main actor-isolated instance method '...' cannot be used to satisfy nonisolated protocol requirement コンテキスト不一致 約2% sending引数の データ競合 sending '...' risks causing data races Sendable / コンテキスト不一致 約2% static/class変数のデータ競合 static property '...' is not concurrency-safe because it is nonisolated global shared mutable state Sendable / コンテキスト不一致 約2% 上の表を見てわかるように次の2つに関連する警告がほとんどでした。 Sendableに非準拠 コンテキストの不一致 Sendableに準拠できていないことによる警告は、Non-SendableのクラスなどをActor境界を超えて利用したり、Sendable指定の引数に渡したりしたときに発生します。コンテキストの不一致は、あるActorから別のActorのプロパティやメソッドを利用していると出る警告です。すべての警告を本記事で解説するのは難しいですが、警告内容が異なっても同じような直し方や考え方が非常に多いです。 Sendable非準拠の警告原因 FAANSでSendable非準拠の警告が最も発生したのはAPIレスポンスモデルでした。API通信でOpenAPI(Swagger)を用いており、自動生成されたコードを利用しています。レスポンスの型はpublicなstructやenumでしたが、publicな型は暗黙のSendable準拠が行われないため、明示的にSendableを付与する必要があります。そのため、次のコードのようにUICollectionViewで指定するアイテムの型がSendableに準拠する必要があるので、警告が出ていました。 解決方法 移行当初、Swift 6用の自動生成テンプレートがBeta版だったため利用を見送りました。テンプレートのコードにSendableが付与されていることを確認し、暫定対応としてimport文に@preconcurrencyを付与して警告を抑制しました。現在はすでにStable版がリリースされているため、今後対応する場合はSwift 6用のテンプレートを利用することで解決可能です。FAANSアプリにおいても近いうちに対応を予定しています。 コンテキストの不一致について 他の警告も簡単に解決できるとよいのですが、これから登場するコンテキストの不一致にはさまざまなパターンがあり、一筋縄ではいかないケースもありました。しかし、このコンテキストの不一致を理解すれば、Swift 6対応において発生する警告のほとんどを解決できます。次の3つについて実例を交えて説明します。 nonisolatedのコンテキストでMainActorを利用するケース クロージャ内がnonisolatedになるケース nonisolatedなプロトコルをMainActorで実装するケース 1. nonisolatedのコンテキストでMainActorを利用するケース nonisolatedのコンテキストでMainActorのメソッドやプロパティを利用したことが原因で発生する警告を解説します。FAANSでは一覧表示のためにUICollectionViewを多くの画面で利用しており、セクションごとにレイアウトを切り替える実装をしています。セクション名をenumで管理してその中でレイアウトを生成するメソッドを定義しています。しかしenum自体はnonisolatedなコンテキストであるため、MainActorのUICollectionViewLayoutなどを扱うと警告が発生しました。 また、UINavigationControllerを設定するためのヘルパー関数があります。UINavigationControllerはMainActorに隔離されているため、そのヘルパー関数をnonisolatedなstructに定義すると警告が出ました。 解決方法 enumやstructに@MainActorを付与してMainActorと同じコンテキストにそろえました。このように単にMainActorにできていなかったというケースは非常に多く、機械的に修正可能です。 2. クロージャ内がnonisolatedになるケース 次に、クロージャ内がnonisolatedになるケースを紹介します。クロージャ内のコンテキストは利用側と同じ場合もあれば、異なる場合もあります。コード例を用いて説明します。 通常のクロージャは利用側のクラスのコンテキストを引き継ぎます。上記のコード例はMainActorのクラスなのでクロージャ内もMainActorです。一方、Sendableを付与したクロージャはnonisolatedと判断されます。nonisolatedのコンテキストでMainActorのプロパティを変更しているので警告が出ます。この警告を解決するにあたって、実務で遭遇したケースを紹介します。 Sendableクロージャにおけるケースの実例 FAANSアプリではKingfisherという画像ダウンロードライブラリを利用しています。downloadImageメソッドで画像をダウンロードし、後続処理をクロージャ内で実装しています。発生した警告についてコード例とともに説明します。 クロージャ内で Capture of 'self' with non-Sendable type 'Downloader?' in a '@Sendable' closure の警告が出ました。はじめに示した例ではMainActorのクラスでSendableクロージャを利用しましたが、このケースではクラスにMainActorが付与されていません。Sendableクロージャで扱う値はSendableである必要があるので、Non-Sendableのselfをキャプチャしたことで警告が出ました。 ViewModelクラスはダウンロードした値を格納し、状態を持つのでSendableにするのは難しい状況です。実際のコードではUIViewControllerがこのクラスを保持しています。UIViewControllerはMainActorなのでViewModelクラスもMainActorにしました。 しかし、Sendableのクロージャはnonisolatedと判断されるため、nonisolatedのコンテキストでMainActorのプロパティを変更できないという警告が新たに出ました。 解決方法1. Task { @MainActor in } の利用 nonisolatedのクロージャ内でTask { @MainActor in }を利用してMainActorのコンテキストで値をセットするように変更しました。 @MainActor class ViewModel { var coverImage : UIImage? func setImage (url : String ) { guard let imageURL = URL(string : url ) else { return } KingfisherManager.shared.downloader.downloadImage( with : imageURL ) { [ weak self ] result in // クロージャ内はnonisolatedとして推論 switch result { case .success( let value ) : // MainActorのコンテキストに切り替える Task { @MainActor in self ?.coverImage = value.image } case .failure : break } } } } 解決方法2. async関数の利用 ライブラリによっては同じ内容のメソッドのasync版が提供されている場合があります。KingfisherのdownloadImageメソッドにもasync版があります。ネストを減らせて可読性が向上し、Sendableクロージャを考慮する必要がなくなります。 @MainActor class ViewModel { var coverImage : UIImage? func setImage (url : String ) { guard let imageURL = URL(string : url ) else { return } // Taskはコンテキストを引き継ぐのでTask { @MainActor in } としなくてもよい Task { [ weak self ] in do { let result = try await KingfisherManager.shared.downloader .downloadImage(with : imageURL ) self ?.coverImage = result.image } catch { // エラー処理 } } } } FAANSアプリはasync対応を進めているので、この解決方法2を採用しました。一方、解決方法1を採用しているケースも存在します。例えば、KVOのobserveで値を監視し、そのクロージャ内でViewの更新処理を行っている箇所がその一例です。observeのchangeHandlerはSendableクロージャなので、クロージャ内でTask { @MainActor in }を使ってMainActorのコンテキストに切り替えました。 private var observation : NSKeyValueObservation? private func setupCollectionViewContentSizeObserver () { // UICollectionViewのcontentSizeをobserveで監視 observation = collectionView.observe( \.contentSize, options : [ .new ] ) { _, change in // クロージャ内はnonisolatedなのでMainActorのコンテキストに切り替える Task { @MainActor [ weak self ] in guard let contentSize = change.newValue else { return } self ?.onCollectionViewContentHeightDidChange?(contentSize.height) } } } 3. nonisolatedなプロトコルをMainActorで実装するケース 次は、nonisolatedなプロトコルを利用するケースです。プロトコルがnonisolatedとして定義されていますが、実装側がViewControllerなどでMainActorになっている場合に出る警告です。具体例を見ていきましょう。 QRコードの読み取りのためにQRScannerというライブラリを利用しています。QRScannerViewDelegateをMainActorのQRCodeScannerViewControllerで実装すると2つの警告が出ました。 1つ目はQRScannerViewDelegateプロトコルへの適合に関する警告です。MainActorに隔離されたコードを跨いでおり、データ競合を引き起こす可能性があると指摘されています。 2つ目はMainActorに隔離されたインスタンスメソッドがnonisolatedの要求を満たせていない、という警告です。 つまり、プロトコルはnonisolatedであるため実装側のMainActorにコンテキストをそろえられません。その解決方法を自作プロトコルと、ライブラリのプロトコルの2つのケースで説明します。 自作プロトコルのケース QRScannerViewDelegateは変更できませんが、自分で定義したプロトコルだと比較的簡単に解決できるケースが多いです。キーボードの表示/非表示に合わせて処理を実行するKeyboardShowableプロトコルの例をコードとともに説明します。キーボードに関する操作なのでMainActorのViewControllerで利用しています。しかしprotocol側はMainActorではなくてnonisolatedなので同じように警告が出ました。 解決方法として、protocol KeyboardShowableにMainActorを付与します。 // @MainActorを付与してKeyboardShowableをMainActorのコンテキストにする @MainActor protocol KeyboardShowable {     func keyboardWillShow (_ notification : Notification )     func keyboardWillHide (_ notification : Notification ) } extension UploadCodeViewController : KeyboardShowable { func keyboardWillShow (_ notification : Notification ) { buttonsBackgroundView.isHidden = true } func keyboardWillHide (_ notification : Notification ) { buttonsBackgroundView.isHidden = false } } KeyboardShowableプロトコルはUIに関することなのでMainActorで利用されると考えてよく、MainActorを付与することで利用側とコンテキストを一致させました。このケースのように自作プロトコルにおいてはプロトコル側の修正をすることで対応が可能です。 ライブラリのプロトコルのケース QRScannerViewDelegate等、ライブラリ側の修正ができないケースについて説明します。MainActorのコンテキストに合わせられないので、実装側のメソッドにnonisolatedを付与してライブラリ側のコンテキストに合わせました。しかしnonisolatedのコンテキストでMainActorのメソッドを呼び出している箇所があり、コンテキスト不一致の警告が新たに発生しました。 解決方法として、Sendableクロージャのケースで紹介した対応と同じように、Task { @MainActor in } を利用してMainActorで実行すると良いでしょう。一方で、Taskを使う処理は非同期に実行されるため、呼び出し元が同期的な完了を前提としている場合は注意が必要です。 extension QRCodeScannerViewController : QRScannerViewDelegate { // nonisolatedを付与 nonisolated func qrScannerView ( _ qrScannerView : QRScannerView , didSuccess code : String ) { // Task { @MainActor in ... }を使ってMainActorのコンテキストで非同期実行 // delegateメソッドの呼び出し元が同期的な完了を前提としていないかは確認が必要 Task { @MainActor in ... qrScannerView.stopRunning() ... } } } 実行時にクラッシュするケースの紹介 nonisolatedを付与して問題を解消する方法に加えて、@preconcurrencyによって警告を抑える選択肢もあります。ライブラリ側のconcurrency対応待ちや既存実装の動作確認が済んでいて挙動を変えたくない場合に、Swift 6移行を進めるための暫定策として利用できます。Swift 5のビルドでは問題なく動作していましたが、Swift 6のビルドで実行時にクラッシュするケースがあったので紹介します。 FAANSアプリでは文字を認識する機能があります。AVCaptureSessionを利用して、AVCaptureVideoDataOutputSampleBufferDelegateのcaptureOutput(...)で出力処理の実装をしています。nonisolatedを付与してTask { @MainActor in … }で必要に応じてMainActorのコンテキストに切り替えました。しかしcaptureOutputの引数がNon-SendableでMainActorに渡せない問題がありました。そのため、@preconcurrencyをつけて対応しました。 // @preconcurrencyをつけて警告を消す extension CameraViewController : @preconcurrency AVCaptureVideoDataOutputSampleBufferDelegate { // nonisolatedをつける必要がなくなる func captureOutput ( _ output : AVCaptureOutput , didOutput sampleBuffer : CMSampleBuffer , from connection : AVCaptureConnection ) { guard let cvBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } // MainActorのメソッドの処理 detectText(buffer : cvBuffer ) } } Swift 6のビルドで動作確認したところ、クラッシュが発生しました。 クラッシュの原因 クラッシュの原因を説明します。AVCaptureVideoDataOutputSampleBufferDelegateはMainActorのViewControllerで利用されています。一方でcaptureOutput(...)はAVCaptureVideoDataOutputに設定した出力キュー上で呼び出されるため、このメソッドはバックグラウンドスレッドで実行されます。 @preconcurrencyを付与するとnonisolatedをつけなくても警告を抑制できるため、captureOutput(...)をMainActorに隔離されたまま実装できてしまいます。しかし@preconcurrencyはコンパイル時のチェックの緩和であり、実行コンテキストまで変えるわけではありません。 Swift 6でビルドすると、どのスレッドで実行されているかのチェックが強化されています。MainActorに隔離されたメソッドが実際にはバックグラウンドスレッドから呼ばれると、実行コンテキストの不一致としてクラッシュしてしまいました。実際のクラッシュログは次の通りです。 // frame#3, frame#4, frame#5がSwift Concurrencyの実行時チェックに関するフレーム // 現在の実行コンテキストが、そのメソッドに要求されるexecutorと一致しているかを検証 (lldb) bt * thread # 35 , queue = 'cameraQueue', stop reason = EXC_BREAKPOINT (code = 1 , subcode = 0x1052ff8e4 ) * frame # 0 : 0x00000001052ff8e4 libdispatch.dylib`_dispatch_assert_queue_fail + 120 frame # 1 : 0x000000010533601c libdispatch.dylib`dispatch_assert_queue $V2 .cold. 1 + 116 frame # 2 : 0x00000001052ff868 libdispatch.dylib`dispatch_assert_queue + 108 frame # 3 : 0x0000000186fc903c libswift_Concurrency.dylib`_swift_task_checkIsolatedSwift + 48 frame # 4 : 0x0000000187028744 libswift_Concurrency.dylib`swift_task_isCurrentExecutorWithFlagsImpl(swift :: SerialExecutorRef , swift :: swift_task_is_current_executor_flag ) + 356 frame # 5 : 0x0000000186fc8d88 libswift_Concurrency.dylib`_checkExpectedExecutor(_filenameStart : _filenameLength : _filenameIsASCII : _line : _executor : ) + 60 frame # 7 : 0x00000001af2249c4 AVFCapture` - [AVCaptureVideoDataOutput _processSampleBuffer : ] + 300 frame # 8 : 0x00000001af2246f8 AVFCapture`__47 - [AVCaptureVideoDataOutput _updateRemoteQueue : ]_block_invoke + 88 frame # 9 : 0x00000001b2b1c1d8 CMCapture`__FigRemoteOperationReceiverCreateMessageReceiver_block_invoke + 104 frame # 10 : 0x00000001b2fb7424 CMCapture`__rqReceiverSetSource_block_invoke + 260 frame # 11 : 0x00000001053162e0 libdispatch.dylib`_dispatch_client_callout + 16 frame # 12 : 0x00000001053000d8 libdispatch.dylib`_dispatch_continuation_pop + 672 frame # 13 : 0x000000010531618c libdispatch.dylib`_dispatch_source_latch_and_call + 448 frame # 14 : 0x0000000105314cd4 libdispatch.dylib`_dispatch_source_invoke + 872 frame # 15 : 0x0000000105304988 libdispatch.dylib`_dispatch_lane_serial_drain + 344 frame # 16 : 0x00000001053057d4 libdispatch.dylib`_dispatch_lane_invoke + 432 frame # 17 : 0x0000000105311b20 libdispatch.dylib`_dispatch_root_queue_drain_deferred_wlh + 344 frame # 18 : 0x00000001053111c4 libdispatch.dylib`_dispatch_workloop_worker_thread + 752 frame # 19 : 0x00000001e56b13b8 libsystem_pthread.dylib`_pthread_wqthread + 292 安易に@preconcurrencyをつけて警告を無視すると、実行時にクラッシュするリスクがあるので注意が必要です。 Combineでコンテキスト不一致によるクラッシュのケース 別のクラッシュ事例として、Combineの使用箇所で発生したケースを紹介します。API通信は基本的にasyncメソッドへ移行していますが、一部ではまだCombineを使っています。バックグラウンドで発火したPublisherの結果をMainActor隔離のViewModelでsinkしている箇所がありました。このsinkのクロージャ実行直前でクラッシュが発生しました。 URLSession.shared.dataTaskPublisherでバックグラウンドから取得した値を、MainActor隔離クラスのsinkで受け取るコード例を紹介します。 struct ImageDownloader { // バックグラウンドスレッドでダウンロード後にPublisherを返す func dataTaskPublisher ( for url : URL ) -> AnyPublisher < Data , Error > { return URLSession.shared.dataTaskPublisher( for : URLRequest (url : url )) .tryMap { (output) -> Data in guard let urlResponse = output.response as? HTTPURLResponse else { throw ImageDownloaderError.unknown } switch urlResponse.statusCode { case 200 ..< 300 : return output.data default : throw ImageDownloaderError.responseError } } .eraseToAnyPublisher() } } @MainActor class MainActorViewModel { private var cancellables : Set < AnyCancellable > = [] func download () { // dataTaskPublisherを実行するだけではクラッシュしない let publisher = ImageDownloader().dataTaskPublisher( for : URL (string : "..." ) ! ) // sinkのタイミングでクラッシュする publisher .sink( receiveCompletion : { _ in }, receiveValue : { data in // .. ダウンロード後の処理 } ) .store( in : & cancellables) } } クラッシュの原因は、メインスレッドに戻すための .receive(on: DispatchQueue.main) をsinkの前に挟み忘れていたことでした。Swift 6でビルドすると、Combineにおいても実行時のコンテキストのチェックが強化されています。しかし、sinkのクロージャの実行コンテキストはコンパイラが静的に追跡できないため、警告が出ないケースもあります。 .receive(on: DispatchQueue.main) をsinkの直前に挿入することで、sinkクロージャの実行コンテキストをメインスレッドに切り替え、クラッシュを回避できます。 @MainActor class MainActorViewModel { private var cancellables : Set < AnyCancellable > = [] func download () { let publisher = ImageDownloader().dataTaskPublisher( for : URL (string : "..." ) ! ) // .receive(on: DispatchQueue.main)でメインスレッドに切り替える publisher .receive(on : DispatchQueue.main ) .sink( receiveCompletion : { _ in }, receiveValue : { data in // .. ダウンロード後の処理 } ) .store( in : & cancellables) } } sendingキーワードについて 次に、sendingキーワードを説明します。警告の数自体はそれほど多くないのですが、理解がやや難しい警告であるため、ぜひ触れておきたい内容です。FAANSではCombineを用いたAPI通信のasync対応でwithCheckedThrowingContinuationを利用しています。クロージャでcontinuationを受け取り、resumeメソッドを実行する部分があります。リクエストした結果の値(value)をresumeメソッドに渡している箇所で警告が出ました。 Task-isolatedとsending parameterの意味がわかりにくいかもしれません。まずはsendingキーワードをコード例と一緒に説明します。 sendingキーワードは関数の引数に付与できます。sendingを付与した場合、Non-Sendableな値でもActor境界を超えられます。しかし引数として渡した値を呼び出し元で利用できなくなります。実際にコード例として、sendingを付与したreceiveWithSendingメソッドにNon-Sendableのクラスを渡すケースを紹介します。 useCounterAfterSendingメソッドで、counterがsendingパラメータとして渡された後に使用されており、後続の使用によるデータ競合の可能性を示す警告が出ました。一方、sendingメソッドでは呼び出し元でcounterを利用していないので警告が出ていないことを確認できます。 Region Based Isolationについて sendingに関連する話題として、Region Based Isolationについて説明します。sendingを付与しているとNon-Sendableの値を送れると説明しましたが、実はsendingキーワードをつけなくてもコンパイラが同じように判断します。次のコードのようにsendingを付与していないreceiveWithoutSendingメソッドにNon-Sendableのクラスを渡しても警告が出ません。また、useCounterAfterSendingメソッドでは呼び出し元でcounterを利用しているので先ほどと同じ警告が出ます。 コンパイラが判断するならばsendingキーワードが不要に見えますが、必要になるケースを紹介します。上のコード例ではNonSendableCounterクラスをその場で初期化して渡しているため、安全に受け渡せるとコンパイラが判断します。しかし、少しコードを複雑にするとコンパイラが安全性を判断できなくなるケースがあります。 例えばNonSendableCounterを戻り値の型とするメソッドで値を取得してからその値を渡すと警告が発生しました。そこで、sendingをメソッドの戻り値の型に付与すると警告が解消されることを確認できました。戻り値にsendingをつけることで、その値が所有権ごと安全に受け渡されることを明示できます。 Task-isolatedな値について 次に、Task-isolatedを見ていきましょう。もう一度、はじめに紹介したwithCheckedThrowingContinuationのコード例を紹介します。 continuation.resumeメソッドの引数にsendingキーワードが付与されているので、sending parameterに関する警告が出ています。 しかし、コードを見る限りvalueを受け取りresumeに渡した後は後続で使っていないように見えます。ここで、Task-isolatedなvalueの意味が重要なので説明します。Task-isolatedの警告の別のケースを見てみましょう。 Task-isolatedの警告が出ました。Taskの外から中に送ると、Task-isolatedになるとわかります。Uses in callee may race ... の警告は、Task-isolatedの利用と呼び出し先の利用でデータ競合が起きるかもしれないという意味です。つまり、呼び出し元でどのように使われるかをコンパイラが判断できないので警告が出ます。一方で、Taskの中で変数を定義した場合はスコープが明確なので警告が出ません。 Combineのsinkする値について Combineのsinkで利用する値はsinkの外で初期化されているため同じくTask-isolatedと判断されます。次のCombineのコード例のように、FutureをsinkするとTask-isolatedの警告を確認できます。 すでに紹介したwithCheckedThrowingContinuationのコード例でも同様にCombineのsinkを利用していたため、Task-isolatedの警告が出ました。警告の解決方法として、送る値をSendableにするか、利用するクラスをMainActorにします。sink内外で同じコンテキストにそろえることで警告が消えます。 その他の警告 最後に、これまでに説明したことを踏まえて解決できる警告を2つ紹介します。 1. passing closure as a 'sending' parameter risks causing data races sendingパラメータの警告で警告内容がわかりにくいケースを紹介します。 クロージャ自体の原因ではなくキャプチャしている値が原因です。TaskのクロージャにNon-Sendableなselfを渡していますが、selfの利用をTaskの中のみに限定できないので警告が出ています。ClassをMainActorにするか、Sendableに準拠すれば解決します。 2. static property '...' is not concurrency-safe because it is nonisolated global shared mutable state シングルトンクラスなど、static変数/class変数をnonisolatedのコンテキストで利用しているケースです。 定数のみ利用しているならばstructにするといったリファクタリングで対応可能です。状態を持つケースではMainActorを付与して利用側とコンテキストをそろえることを検討してください。 まとめ 本記事ではSwift 6対応を始める前に知っておくと役立つポイントを紹介しました。警告の大半はSendable非準拠とコンテキストの不一致の2種類でした。特にコンテキストの不一致がSwift 6移行における最重要ポイントです。また、Swift 6でビルドが成功しても、実行時のクラッシュが発生するリスクは残っているので動作確認も大事です。本記事が移行作業の一助になれば幸いです。 さいごに ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは。コーポレートエンジニアリング部ITサービスブロックの高橋です。 当社はJira / Confluence Data Center版からAtlassian Cloudに移行しました。今回は、実務で直面した課題を交えてその経験をお伝えします。 目次 はじめに 目次 なぜ移行したのか 移行の進め方 アプリ移行を重視した理由 事前準備 組織統合の整理 SSO/SCIMと権限設計 グループ同期の注意ポイント UAT(ユーザ受け入れテスト)の設計と運用 ユーザへの案内内容 アプリ確認を支えるガイド 問い合わせの集約方法 本番移行 移行後の対応 権限の付け替えとAPI活用 実際にハマったポイント 外部サービスに残る旧URLへの対応 移行を終えて おわりに なぜ移行したのか 私たちが移行を検討した理由は大きく2つあります。1つはユーザ目線、もう1つは管理者目線です。 ユーザ目線として、Data Center版では最新機能が利用できず、ユーザ体験を損なっていました。 UIの違い:Confluenceのライブ編集エディタなど、Cloud版のUI改善がData Center版には反映されない SaaS間連携の制約:Slackとの連携などCloud前提の機能が多く、Data Center版では手段が限られる 自動化機能の差:Jira AutomationのトリガーやテンプレートがCloud版の方が充実している Cloud限定の新機能:Atlassian IntelligenceやJiraのチーム管理対象プロジェクトが利用できない 管理者目線では、管理・運用負荷やセキュリティ対応の負担が増していました。Cloud版ではこれらが以下のように改善されます。 定期メンテナンスによる停止 → 自動アップグレードによりダウンタイムが不要に 都度必要になるストレージ増設 → Atlassianが管理するため不要に 定期アップグレードや脆弱性対応 → 自動適用されるため運用負荷が軽減 ユーザ管理や権限管理の運用負荷の増大 → SCIMによる自動プロビジョニングが利用可能に セキュリティ対策の個別対応 → Atlassian Guardによるデータ保護や脅威検知の一元管理が可能に Data Center版のサポート終了(2029年3月28日にEOL予定) 以上を踏まえ、最終的にEOLの明確化が決定打となり、ユーザ利便性の向上と管理コストの削減を目的にAtlassian Cloudへの移行を決めました。 移行の進め方 移行は「調査→検証(UAT)→本番」の段階で進め、2024年に情報収集と方針決定、2025年上期に移行準備とUAT、下期に本番移行するスケジュールで実施しました。 移行作業の大部分はSIに委託しました。具体的には以下のような作業です。 Atlassian純正移行ツールによるデータ変換・投入 Jira / ConfluenceのデータをCloud Migration Assistantで変換・投入し、ボードやページの移行可否を検証するためにツールの最新版で再移行テストを実施。 添付ファイル移行(Jira / Confluence)の個別実行と複数回の実施 添付ファイルは別工程で移行し、Jira添付→Confluence添付の順で実行。完了しない箇所は再移行・調整を繰り返した。 本番移行の手順実行(停止→再移行→確認) 旧環境の停止(夜間)→Jiraデータ移行→Confluenceデータ移行→データ確認、という順序で本番移行を実施(移行日程・停止時間の調整を含む)。 アプリ/マクロの個別移行・チューニング ユーザーマクロやアプリ固有の変換不具合に対し、対象スペースでの変換・調整・チューニングを実施。必要箇所は再移行や個別チューニングを依頼。 リダイレクト/旧環境の読み取り専用化等の運用設定 移行後のリダイレクト設定や旧環境を読み取り専用に切り替える作業を実施。 また、移行後のリスクに備え、旧Data Center版の環境は保険的に半年間維持する方針としました。 アプリ移行を重視した理由 (※以下、旧来の呼称である「アドオン」を含め、本記事では「アプリ」と表記します) 当社の環境は、長年の運用で蓄積されたデータ量が膨大であり、細部まで逐一確認できる状況ではありませんでした。そのため、一般的な表示や日常機能の微細な差分をすべて確認するのではなく、データ変換で致命的な問題が発生しないことを前提としました。移行時の最重点は、データ変換だけでは対応できないアプリの検証に置きました。 アプリ移行が厄介なのは、「変換してみないと変換精度や変換後の実行エラーが判明しない」点です。 Data Center版のマクロやカスタムスクリプトは、Atlassian Cloudの標準機能に置き換えられる場合もあれば、別のアプリで代替するしかない場合もあります。代替先に移す場合でも挙動や仕様が異なるため、変換ツールがあっても正常に動作するか、どの程度エラーが発生するかは試してみないと分かりません。 そのため実務では、検証環境で変換テストを行い、UATで例外を拾い、検出した問題をSIに返して対応・再変換するという反復作業を行いました。 すべてを完璧に移行するのは現実的でないため、アプリの使用件数など影響度で優先順位を付けました。軽微・特殊な例外はユーザ側で手作業により補完する方針です。 事前準備 組織統合の整理 移行検証で意外に問題となったのが、グループ内の別組織がすでにAtlassian Cloudを契約していた点です。 このまま別々の組織として運用すると、SSOやプロビジョニングの設定も分断され、ユーザ管理や権限管理の運用コストが大きくなるため、組織を統合する方針としました。 具体的には、既存の組織を当社側で管理する形に移管し、その組織内でサイトを分けて新規サイトに移行する構成としました。既存環境を壊すことなく統合でき、複数サイトのアクセス権を組織グループで一元管理できるようになりました。 このような対応は、組織間の調整やAtlassianとの事務的な手続きを伴います。技術的な作業とは別に時間がかかるため、移行の早い段階で整理しておくことで、後工程での手戻りを防ぐことができます。 SSO/SCIMと権限設計 Atlassian Cloudでは、Data Center版と比べて認証・ユーザ管理の考え方が変わります。Data Center版でもSSO自体は利用できますが、Atlassian Cloudでは組織単位でのユーザ管理やサイト単位でのアクセス制御、SCIMによる自動プロビジョニングが前提です。ユーザやグループの管理がよりIdP寄り(当社ではEntra ID)になります。 この違いにより、単純に既存の権限設計をそのまま移すのではなく、ユーザ管理と権限管理をあわせて見直す必要があると判断しました。 Entra IDを中心にSSOを構成し、ログインを一本化(以前と同様) Entra IDの組織グループを活用した権限管理に移行(Data Center版では静的グループを中心に、一部スクリプトで擬似的な同期を行っていた) SCIMによる自動プロビジョニングを導入し、手動・スクリプトベースのユーザ/グループ管理を削減 移行時はユーザ重複やドメインを事前に整理し、UATで実際のアクセスを確認 Data Center版では実現できていなかった「IdP側のグループと権限の連動」を前提に設計し直したことで、移行後のユーザ管理の手間を大きく減らすことができました。 グループ同期の注意ポイント SCIMによるグループ同期を行う際、同期したいグループと同名のグループが既に存在する場合、同期前に変更内容のレビューがUI上で求められる点にも注意が必要です。この場合、どのユーザが追加/削除されるかを事前に確認し、承認したうえで同期を実行する必要があります。 既存グループとの衝突が発生すると、そのままでは同期できないケースもあるため、グループ設計や命名の整理を事前に行っておくことが重要です。 support.atlassian.com UAT(ユーザ受け入れテスト)の設計と運用 UATは約1か月間設けました。検証用のAtlassian Cloud環境を構築し、そこにData Center版のデータを変換して投入し、ユーザが自由に触れる状態で検証を進めました。検証環境のデータは本番に引き継がれません。したがって検証で「再設定が必要だ」と判明した箇所は、検証中に手順を確認してもらいました。本番の利用開始後に改めて同じ手順で再設定していただく前提です。検証環境は「変換後の動作確認と再設定手順の確認の場」と位置づけました。再変換で潰しきれないものを本番で再設定していただく運用にしました。 ユーザへの案内内容 基本動作: ログインや基本機能に問題がないか。 業務の継続性: 普段の業務がこれまで通りスムーズに行えるか。 アプリの重点確認: マクロや自動化を多用しているユーザには、アプリの動作を入念に確認するよう依頼。 ライトユーザへの負荷設計: 複雑な機能を使っていないライトユーザには、単純に「いつもの業務ができるか」だけを確認してもらい、全体の負担を抑える。 アプリ確認を支えるガイド ユーザが確認しやすいように、ガイドとなる資料を配布しました。資料は対象アプリごとに「どこで使用されているか」「移行されないもの」「正常に移行される想定のもの」「各自で再設定や運用変更が必要になりそうなもの」をざっくりリストアップしたものにしました。 問い合わせの集約方法 問い合わせや不具合報告は専用のSlackチャンネルに集約し、Slackワークフローでフォーム化して問い合わせを集計できるようにしました。報告された事項は順次SIに確認・修正を依頼しました。 本番移行 本番移行のタイムスケジュールは以下のとおりです。 10/03(金):検証環境の再構築 10/06(月)〜 10/10(金):添付ファイル移行(複数回実施) 10/31(金)22:00:Data Center版の本番環境を停止・読み取り専用に設定 11/01(土)深夜〜11/02(日):本番の移行作業(Jiraの移行対応→Confluenceの移行対応) 11/02(日):移行後データ確認、リンク修正、設定修正 11/03(月):当社側でのデータ確認・結果判定 11/04(火):Atlassian Cloud運用開始 予備日:移行の結果判定がNGの場合や追加対応が必要な場合に備え、11/22(土)〜11/24(月)を予備日として確保(連休をターゲットに) 事前にUATで十分に検証していたため、本番移行で大きな問題は発生しませんでした。また、UAT期間中にアプリ周りのデータ変換を複数回繰り返していたことにより、変換精度はある程度向上しており、本番時のリスクを抑えることができました。 移行後の対応 権限の付け替えとAPI活用 本番移行の完了後、各スペースの権限を既存の静的グループからEntra IDと同期した組織グループに付け替えるにあたり、Atlassian CloudのAPIを利用して一括付け替えを実施しました。 あらゆるグループを差し替えたわけではなく、トップレベルや本部単位の主要グループを対象に差し替えています。これは入退社によるアクセス権管理を自動化するためです。 権限の付け替えは対象が増えると手作業では現実的ではないため、スペース単位で整理した内容をもとにAPIを使って一括で処理する形にしました。 基本的には以下の流れで進めました。 スペース一覧を取得 既存の権限(グループ)を取得 新しいグループ構成との差分を整理 APIで権限を追加/削除する また、権限の付け替えは一度にすべて入れ替えるのではなく、まず新しいグループの付与が正しく行われていることを確認したうえで、既存の権限を削除するようにしました。 実際にハマったポイント Atlassian CloudのAPIは、トークンの種類によってリクエスト方法が変わります。 スコープなし(従来のAPIトークン) スコープ付きトークン(推奨) それぞれリクエストURLの形式が異なります。 Confluence(スコープなし): https://<site>.atlassian.net/wiki/rest/api/space Confluence(スコープ付き): https://api.atlassian.com/ex/confluence/<cloudId>/wiki/rest/api/space Jira(スコープなし): https://<site>.atlassian.net/rest/api/3/project Jira(スコープ付き): https://api.atlassian.com/ex/jira/<cloudId>/rest/api/3/project スコープ付きトークンではapi.atlassian.comベースのURLにCloud IDを含める必要があります。Cloud IDはサイトURLと異なる識別子であり、これを含めてリクエストを構築しないとAPIは動作しません。 Cloud IDは以下の方法で確認できます。 admin.atlassian.comのURL /s/{cloudId} から確認 https://<site>.atlassian.net/_edge/tenant_info にアクセスして取得 詳細は公式ドキュメントを参照してください。 support.atlassian.com support.atlassian.com また、Atlassian CloudのAPIはバージョン(v2 / v3)によって仕様が異なります。基本的な操作は共通しているものの、レスポンス形式や扱える項目に差があるため、実装時にはドキュメントを確認しながら利用する必要があります。 外部サービスに残る旧URLへの対応 旧Data Center版の環境は保険的に半年間維持する方針としましたが、廃止後は完全にアクセスできなくなります。Jira / Confluence内のリンクは移行時にある程度変換されます。一方、SlackなどAtlassian外のサービスに記載されたURLは自動で置き換わらないため、そのままではリンク切れが発生します。 そのため、旧環境と新環境のページURLの対照表の作成をSIに依頼し、必要に応じて参照できるようにしました。これにより、移行後も外部に残っているリンクから該当ページを特定できるようにしています。(ページの一覧はData Center版ではDBから取得、Atlassian Cloud環境ではREST APIで取得可能) 移行を終えて 社内からの問い合わせ件数は全体で約100件でした。多くはアプリのデータ変換や表示差分、操作方法の違いに関するもので、致命的な障害は発生していません。 ただし、移行ツールやSI側の対応だけでは解決できない細かなケースもあり、一部ユーザ側で手動修正が必要になりました。主な例は次のとおりです。 マクロの差し替え:旧環境で使用されていたマクロの一部がAtlassian Cloudで正常に表示されず、代替マクロへの差し替えや再作成が必要になった Plans/ガントの再設定:表示やフィルタ設定の一部が引き継がれず、再設定が必要になった JQLの書き換え:旧環境で使えていたJQL関数がAtlassian Cloudで使えず、代替クエリへの書き換えが必要になった Automationの再構築:トリガーや参照フィールドの仕様差で動作しないルールがあり、修正・再作成が必要になった この大規模な移行プロジェクトは何が起きるか分からず、計画初期から漠然とした不安がありました。移行ツールの質も上がっているからか、特に大きなトラブルもなく移行でき、ほっとしました。 アプリ周りの移行が最も大変でしたが、見込み通りだったのは幸いでした。変換方法についてSI側と協議しながら再変換を繰り返すなど、手探りな部分はどうしても発生します。手動での修正は、対象が多すぎると現実的でないため、できる限り変換で潰していくことが重要です。変換処理は繰り返す前提で、早いうちから検証を進めることをおすすめします。 おわりに Data Center版からAtlassian Cloudへの移行は技術的なデータ移行作業だけではありません。機能面・運用面・契約・組織間調整と、多面的に検討する必要があります。UATを中心に据えて現場で検証し、APIを活用することで作業負荷を抑えられます。同様の移行を検討している現場の参考になれば幸いです。 ZOZOでは、テクノロジーを活用して社内の課題を解決する仲間を募集中です。SaaSの運用管理からシステム間連携・自動化まで、グループ全体の業務環境をよりよくするための取り組みにチャレンジしていきます。カジュアル面談も実施していますので、社内ITについて語りましょう! ご興味のある方は、下記のリンクからぜひご応募ください。 hrmos.co
はじめに こんにちは、ZOZOTOWN開発2部iOSブロックのらぷ( @laprasdrum )です。普段はZOZOTOWN iOSアプリを開発するチームで各メンバーの開発における設計や技術課題のフォローアップを担当しています。また、iOS領域におけるテックリードとして社内の技術共有会や ZOZO.swift などを運営しており、各プロダクトのiOSチーム全体をつなげる横断活動に従事しています。 ZOZOTOWN iOSアプリは2010年11月にリリースされ、15年以上にわたって開発が続くプロダクトです。長い歴史の中でチームと技術が変遷し続け、Fat ViewControllerやObjective-Cコードの残存といった技術的負債を抱えていました。これに対してチームは2023年からアーキテクチャの刷新に本格的に取り組んできました。 本記事では、その3年間の変遷を振り返り、アーキテクチャがどのように進化し、設計をレビューする力がチーム全体にどう広がったかをお伝えします。 なお、チーム運営の全体像は ZOZOTOWNのiOSチームを支えるチーム運用 で紹介しています。Fat ViewController解消の具体的な手法は ZOZOTOWN iOSアプリでのFat ViewController解消への取り組み を参照してください。 目次 はじめに 目次 アーキテクチャ変遷の全体像 最初の一歩 — 2023年の型作り MVVMの採用と方針ドキュメントの策定 アーキテクチャレビューの始動 意思決定を支えた二層構造 アーキテクチャの判断と見直し レイヤー設計の選定と見直し Translatorレイヤーの導入(2024年5月) MVVM + UseCaseの採用(2024年9月) ドメインレイヤーの廃止とRepositoryパターンへの集約(2026年3月) 開発プロセスの進化 定量データで見るチームの変化 アーキテクチャ移行の規模 設計レビューの担い手の逆転 AIによる設計知識のチーム展開 1. Geminiによるレビューコメントの要約 2. Copilotレビュー指示の導入・強化 3. チーム共有コマンドへの昇華 4. アーキテクチャ学習サイトと理解度クイズ まとめ アーキテクチャ変遷の全体像 各トピックの詳細へ入る前に、3年間の変遷と技術スタックの移行を俯瞰します。 まず、主要な取り組みの時系列です。 2023年 — MVVM化の型作り Fat ViewControllerの解消を目指し、対象画面を決めてリファクタリングを実施した。 2024年前半 — Translatorレイヤーの導入・アーキテクチャレビューの再設計 API通信モジュールとUIレイヤーの依存を断ち切るTranslatorを導入した。PRレビュー課題の体系的分析から、関心の分離をレビューする運用に刷新している。 2024年後半 — MVVM化の複数画面への展開・Objective-CからSwiftへの移行 商品詳細を中心にMVVM + UseCaseを適用した(詳細は 前述の記事 を参照)。 2025年 — 大規模画面の並行リファクタリング 大規模画面にMVVM + UseCaseを適用し、Storyboardの一部削除やBaseViewControllerの廃止を進めた。 2025年後半 — ガイドラインと設計制約の明文化 MVVM + UseCase統一ガイドやアーキテクチャガイドラインを策定した。 2026年〜 — Repositoryパターン本格導入 実装経験を経てUseCaseが不要なケースを特定し、Repositoryパターンを本格適用している。 技術スタックも同時期に大きく変わっています。 UIアーキテクチャ(2023〜2026) MVC(Fat ViewController)からMVVM + Repositoryへ移行した。 UIフレームワーク(2023〜2026) UIKit + XIB/StoryboardからUIKit(コードベース)+ SwiftUIへ移行した。 非同期・リアクティブ(2024〜2026) ReactiveSwiftからCombineへ移行し、非同期処理にSwift Concurrencyを導入した。 言語(2023〜2025) Objective-CとSwiftの混在状態からSwift中心へ移行した。Swift比率は94.7%(2023年)から99.5%(2026年)へ向上している。数値上は約5ポイントの変化だが、2025年に削除した12クラスはのべ64箇所から参照されており、1クラスの削除に最大17ファイルの改修を伴った。通常の案件開発と並行しながら段階的に解消した。 テスト(2025〜2026) XCTest + NimbleからSwift Testing + 振る舞い駆動のテスト記述へ段階的に移行している。 これらの移行は一括で実施したのではなく、チームの習熟度に合わせて段階的にスコープを拡大していきました。その意思決定の裏側を以降のセクションで掘り下げます。 最初の一歩 — 2023年の型作り MVVMの採用と方針ドキュメントの策定 Fat ViewControllerの解消にあたり、チームはアーキテクチャとしてMVVMを採用しました。実装に先立ち、2022年末からMVVMの実装方針をチームで統一するためのドキュメント作成が始まり、2023年初頭にWIPを外してチームに公開されました。これにより、MVVM化の共通認識となる土台が整いました。このドキュメントは以降も継続的に更新されています。 アーキテクチャレビューの始動 方針が共有された直後の2023年3月、最初のMVVM化が始まりました。この取り組みで特に意識されたのは、単なる1画面の改修ではなく「今後他の人がMVVMで画面を実装する際の参考になるか」という点です。 実際にこのレビューには25件のアーキテクチャレベルのコメントが付きました。レビュアがデータフロー図を描いてViewModelの状態更新フローを可視化するなど、コードではなく設計を先にレビューする文化の萌芽となっています。 続く2画面目(2023年4月)では、レビュー依頼の本文に設計図を含む「アーキテクチャレビュー」セクションが初めて明示され、テンプレートとして定着しました。 こうしてMVVM化の最初の型は作れたものの、これを複数画面に展開し、チーム全体で設計判断の質を維持していくには、個々のPRレビューだけでは限界がありました。チームとして設計を議論し、方針を決め、知識を共有する仕組みが必要だったのです。 意思決定を支えた二層構造 チーム運用の記事 で紹介した「開発生産性MTG」と「Rethink!」について改めて簡単に説明します。 開発生産性MTG :シニアエンジニアとマネージャーが主導し、コードベースの技術的課題を除くチームの課題を議論する場である(2023年10月〜、約30回開催)。各案件の開発マネジメント、PRレビュー、メンバー間の情報格差など、チームがより成熟するために必要な課題を整理し、アクションを設計していた。 Rethink! :技術的な課題をチームメンバーで定期的に見直す週次勉強会である(2024年2月〜通算90回以上)。技術的負債と感じていること、Appleが提示している技術への所感や適応方針、自分だけしか知らないかもしれないことなど、お題は多岐にわたる。話した内容はアーカイブとして残し、過去の意思決定の資料として参照している。 この2つは、案件開発の流れの中だけで方針を決めず、立ち止まって振り返り分析するためのイベントです。 なお、現在は開発生産性MTGという場を設けなくても、メンバー各々がときに立ち止まり、チームに課題を投げかけるコミュニケーションが日常的に行われるようになりました。立ち止まって考える姿勢が特定の会議体に閉じず、チーム全体に浸透した結果、開発生産性MTGは役割を終えています。 この二層構造から3つの重要な方針が生まれました。コード課題の根本原因の特定、リファクタリングのゴール定義、そしてレビュー工程の分離です。 まず、コード品質の課題を時間をかけて分析し、関心の分離・疎結合がコード課題の原因の8割という結論に到達しました。暗黙知が多く「良いコードとは何か」の定義が揃っていないことが本質的な問題であり、「きれいなコードの定義」を目指すのではなく「アンチパターンの提示」で十分という方針が生まれました。 この分析を受けて、リファクタリングのゴールを「きれいなコードをメンバー全員が理解すること」と定義しました。リリースすることはMUSTではなく、認識を揃えることが目的です。この方針のもと、Rethink!をチーム全体での実践場として活用することが決まりました。 同時に、レビューが特定のメンバーに集中しマージ待ちが常態化していた問題も分析しました。その結果、外部品質(仕様通りに動くか)と内部品質(設計・保守性)の確認がひとつのレビューに混在していることが負荷の原因と判明しました。これを受けて、基本設計レビュー(仕様の漏れと実現可能性)、アーキテクチャレビュー(レイヤー間の関心分離)、コードレビュー(実装)の3段階にレビュー工程を分離しました。 これらの方針は、Rethink!を通じてチーム全体の実践に落とし込まれました。全員が同じ題材で設計を提出して観点を揃えたり、実際のPRを題材にレビューの進め方を学んだりしました。レイヤー設計やレビュー手法といったアーキテクチャの具体的な意思決定もRethink!から生まれています。 開発生産性MTGが方向性を定め、Rethink!がチーム全体で実践し、ドキュメントとして蓄積していくことで、暗黙知が特定の個人に閉じることなく、チーム全体の形式知として更新される構造を実現しました。PRレビューのコメントでも日常の雑談でも、「Rethink!で話してみますか」という言葉が自然に出てくるようになりました。 アーキテクチャの判断と見直し 二層構造の仕組みが実際にアーキテクチャの判断をどう動かしたのか、レイヤー設計と開発プロセスの2つの観点から見ていきます。 レイヤー設計の選定と見直し チームはレイヤー構成を3回にわたって選定・見直しました。 Translatorレイヤーの導入(2024年5月) 「関心の分離が課題の8割」という方針を受けて、最初に着手したのはデータレイヤーの整備でした。ZOZOTOWN iOSでは、API通信の処理をアプリ本体とは独立したモジュールとして切り出しています。このモジュールがAPIリクエストの発行とレスポンスのデコードを担い、アプリ側はモジュールが提供するクライアントを呼び出す構成です。 しかし当時は、このAPI通信モジュールが返すレスポンス型をViewModelがそのまま扱っており、モデル変換のロジックがUIレイヤーに漏れ出していました。UIレイヤーがAPI通信モジュールの型に直接依存する状態です。 この課題に対して導入されたのがTranslatorレイヤーです。API通信モジュールのレスポンスをアプリ内のモデルに変換する責務を一手に担い、UIレイヤーとAPI通信モジュールの依存を断ち切りました。2024年5月に最初の実装がマージされ、同時期にMVVM方針ドキュメントにもTranslatorの項目が追記されています。 この分離は設計方針の浸透とテスタビリティの両面で効果がありました。ViewModelはアプリ内のモデルだけを扱う前提になるため、「ViewModelはアプリ内のモデルを扱う層である」という方針をチームに伝えやすくなりました。また、ViewModelのテストからAPI通信モジュールへの依存がなくなり、テストビルド時に不要なモジュール依存を削除できるようになりました。 Translatorの導入により、データレイヤーの一部が確立されました。次の課題は、ViewModelとこのTranslatorの間、つまりドメインレイヤーをどう設計するかです。 MVVM + UseCaseの採用(2024年9月) データレイヤーやドメインレイヤーの責務分割にはチーム方針がなく、メンバーによるコード品質の差が大きくなるポイントでした。一方で、馴染みのないアーキテクチャを導入してメンバーの認知負荷を上げるわけにもいかず、最低限のレイヤー定義が求められました。開発生産性MTGでUseCaseの責務を議題に絞り込み、Rethink!で3つの選択肢を比較しました。 Androidの推奨アプリアーキテクチャ方式 :UseCaseはオプショナルで、複雑なビジネスロジックのカプセル化や、複数のViewModelから再利用されるロジックの共通化を担う。ただしデータレイヤーのRepositoryがある前提 Clean Architecture + DDD方式 :UseCaseがドメインロジックを担い、ドメインモデルとペアで設計する。画面に紐づかずドメイン単位で定義するため、境界設計が難しくなる MVVM + UseCase方式 :UseCaseがドメインレイヤー(本来のUseCase)とデータレイヤー(Repository)の両方をカバーする。データソースが単一ならRepositoryを別途作るコストをスキップできる チームが選んだのはMVVM + UseCaseでした。ZOZOTOWN iOSではデータソースが単一のケースが多く、Repositoryを別途設ける必要性が低かったためです。「レイヤーの責務が肥大化しすぎた場合はClean Architecture方向に進化させる」という留保付きの判断でした。 ドメインレイヤーの廃止とRepositoryパターンへの集約(2026年3月) この判断のもと、チームは複数画面でMVVM + UseCaseを実装していきました。しかし約1年半の運用を経て、Rethink!で「ドメインレイヤー(UseCase)を廃止し、データレイヤー(Repository)に責務を集約すべきではないか」という議論が持ち上がりました。実装を重ねる中で見えてきたのは以下の点です。 ZOZOTOWN iOSではドメインモデルやそれを用いるビジネスロジックが稀であること APIClientとTranslatorの組み合わせが実質的にRepositoryの役割を果たしており、UseCaseとの責務が重複していたこと UseCaseを設けても責務の理解が揃わず、チーム内に混乱を招いていたこと 重要だったのは「各レイヤーの責務の理解が揃うこと」であり、UseCaseという層を設けること自体が目的ではなかったという気づきです。結果、MVVM + Repository(UseCaseなし)をチームの方針としました。 これは単なる揺り戻しではなく、「レイヤーの責務が肥大化しすぎた場合はClean Architecture方向に進化させる」という最初の留保に対する回答です。実際に使ってみた結果「肥大化ではなく責務の重複が問題であり、むしろ層を減らすほうが適切だった」という結論に至りました。 開発プロセスの進化 同時期に、設計の進め方そのものにも変化がありました。 アーキテクチャレビューでは、当初は重厚なPlantUML図を用いて設計を可視化していました。しかし「作図コストが高い割に手戻りは防げない」ことがわかり、Protocol定義をPRにして設計をレビューする軽量な手法に転換しました(2024年8月)。XIB/Storyboardからの移行方針(2025年4月)など、UIフレームワークの選定もRethink!で議論しています。 こうした議論と意思決定の積み重ねは、チームの活動にどのような変化をもたらしたのでしょうか。定量データで確認します。 定量データで見るチームの変化 アーキテクチャ移行の規模 ViewModel / UseCase / Repository / Translatorを含むコミットメッセージを年別に集計しました。 年 コミット数 前年比 2023 217 — 2024 302 +39% 2025 608 +101% 2026(〜2月) 194 — コミット数の増加と並行して、レガシーコードの削減も進みました。以下は2025年のレガシーコード削減実績です。 指標 2025年1月 2025年12月 削減率 Objective-C .mファイル 19 3 84% Objective-Cコード行数 2,768 788 72% XIB/Storyboard 88 58 34% 特筆すべきは、2024年に積み重ねた依存解消が2025年の大規模削除を可能にした点です。一度に大きく変えるのではなく、依存を個別に剥がし続けたことで、翌年のレガシーコード削減につながりました。 設計レビューの担い手の逆転 アーキテクチャ移行の規模が拡大する中で、その設計レビューを誰が担うかにも大きな変化が起きました。 2023年末時点では、GitHubのCODEOWNERSに筆者のみが設定されており、すべてのPRが筆者のApproveなしにマージできない状態でした。この期間にマージされたPRは月平均約53件にのぼります。一部案件のスプリントレビュー時にレビュー待ちチケットが残留し、「マージ待ちになっている時間が結構ある」という声が上がるほどで、まさに「できる人がやる」体制の限界でした。 2024年6月、この問題を受けて筆者ともう1名のシニアエンジニアによる2名体制に移行し、アーキテクチャレビューとコードレビューの役割を分担しました。同時に、それまでレビューに関わる機会のなかった他のメンバーをピアレビュアとして外部品質のレビューに参加してもらう体制を整え、レビュー文化の醸成とレビュア育成がここから始まりました。 PRレビューコメントのうち設計に関するものを定量分析した結果、シニアレビュアの比率は2024年10-12月の89%から2025年1-3月には25%へ低下しました。2026年1-2月時点では9%まで下がり、ピアレビュアが設計コメントの91%を担っています。2026年2月には固定レビュア制度そのものが廃止され、ピアレビュアのランダム選出に移行しています。 この変化の背景には、開発生産性MTGによる制度設計とRethink!での実践という二層構造の仕組みがありました。 数字の裏にある変化を一人のメンバーの軌跡で見ると、より具体的になります。初期(2024年)はレイアウト設計の範囲内にとどまっていたレビューが、UseCase設計の責務境界の理解(2025年1-3月)を経て成長しました。最終的にはディレクトリ構造・命名規則の統一提案・APIレスポンス型の設計方針など、プロジェクト全体を俯瞰するレビューに到達しています。 設計レビューの担い手が広がる一方で、シニアレビュアが持っていたレビュー観点そのものを、人に依存しない形で残す取り組みも並行して進めました。 AIによる設計知識のチーム展開 アーキテクチャの形式知化の最終段階として、設計知識とレビュー観点をAIを活用した4つの手段でチームに展開しました。 1. Geminiによるレビューコメントの要約 レビュア育成とシニアレビュアへのレビュー依存の解消を目指し、週次でPRレビューコメントを収集しGeminiに要約させてSlackに投稿しています。要約結果をチームの週次定例で振り返り、レビュー観点の共有やレビュア同士の知見交流、まだレビュアになっていないメンバーへのレビュー観点インプットに活用しています。 2. Copilotレビュー指示の導入・強化 GitHub CopilotによるPRへのインラインコメントは、開発者が環境構築や新しいツールを導入する必要がなく、チームメンバーがAIの恩恵を素早く実感できる手段でした。 copilot-instructions.md にレビュー観点を定義し、アーキテクチャ準拠・iOSベストプラクティス・テスタビリティ等の知見を追加しました。 Copilotによるアーキテクチャ関連コメントは月間28件から94件に増加しました(2025年8月から2026年2月)。従来はシニアレビュアが暗黙的にチェックしていた観点が自動的にレビューされるようになりました。 3. チーム共有コマンドへの昇華 レビュー観点をClaude Codeのコマンドとして言語化し、チーム共有のセルフレビュースキルとして整備しました。マージ先ブランチの確認、開発チケットや設計ドキュメントからのコンテキスト取得、アーキテクチャ準拠チェック、動作確認シナリオの生成まで、PR作成前に開発者自身でセルフレビューできるようにしました。 4. アーキテクチャ学習サイトと理解度クイズ アーキテクチャガイドラインやテンプレートの内容をもとに、インタラクティブな学習サイトの生成とCLI上でのクイズによる理解度チェックをClaude Codeのコマンドとして整備しました。レイヤー構成やデータフロー、アンチパターンなどをブラウザ上で視覚的に学べるほか、クイズではドキュメントに明記されたルールから出題し、回答ごとに根拠となるドキュメント箇所を提示します。 この4つは、暗黙知の移転経路がそれぞれ異なります。 Gemini要約 :人のレビューコメントをAIが集約して全員に届ける仕組み Copilot指示 :人からAIへの知識の埋め込みによる自動レビュー セルフレビューコマンド :人がAIを介して自分自身に観点を返す仕組み 学習サイトとクイズ :ドキュメントをAIが対話的な教材に変換する仕組み 経路が異なるからこそ、どれかひとつが欠けても他で補完できる構造になっています。 まとめ ZOZOTOWN iOSチームのアーキテクチャは、2023年の1画面でのMVVM化から始まりました。2024年のTranslator導入と複数画面への展開、2025年の大規模並行リファクタリング、2026年のRepositoryパターンへの集約と段階的に進化しています。チームの習熟度に合わせてスコープを拡大し、実装経験をもとにレイヤー構成そのものを見直す判断もできるようになりました。 この過程を支えたのが、開発生産性MTGで方向性を定め、Rethink!でチーム全体が実践し、ドキュメントとして蓄積していく二層構造の知識形成フローです。暗黙知が特定の個人に閉じることなく、チーム全体の形式知として更新される仕組みを作りました。 さらに、Geminiによるレビューコメント要約、Copilotレビュー指示、Claude Codeのセルフレビューコマンド、アーキテクチャ学習サイトの4つを整備しました。これらを通じて、設計知識とレビュー観点をAIを介してチームに展開しています。 設計レビューの担い手もシニアレビュア89%からピアレビュア91%へ完全に逆転しています。 この変化はチームに具体的な効果をもたらしています。責務やデータフローの定義がチーム全体で揃ったことで、PRレビューの場で合意形成がスムーズに進むようになりました。設計の相談先が特定の個人に閉じなくなり、意思決定の属人化も解消されています。かつては口伝に頼っていた知識が体系化されたことで、新メンバーが自律的に学べる環境も整いました。 「できる人がやる」から「全員で設計をレビューできる」への転換が、アーキテクチャの一貫性とチームのスケーラビリティの両立を実現しています。 ZOZOでは一緒に働くエンジニアを募集中です。ご興味のある方は以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、ZOZOTOWN企画開発部 企画フロントエンド1ブロックの片岡優斗です。ZOZOTOWNでは、セール訴求や新作アイテム訴求、未出店ブランドの期間限定ポップアップ、著名人とのコラボなどの企画イベントが日々展開されています。その集客や回遊の起点となるランディングページを「企画LP」と呼んでおり、私はこの企画LPを主に実装するチームに在籍しています。 本記事では、LP制作における属人的なデザイン管理の課題解決に向けて「LP向けデザインガイドライン」を構築した取り組みをご紹介します。 目次 はじめに 目次 背景と課題 プロジェクトの進め方 プロジェクトの始動と進行の中で見えてきた課題 進め方の改善 ガイドラインの設計 ファイル構成 ガイドライン構成 変更可能な要素 コンポーネント まとめ さいごに 背景と課題 企画LP制作では速く作ることだけではなく、「案件ごとの表現を最大化すること」が求められています。 一方で制作を支えるデザインに関する仕様や参照基準は十分に体系化されていませんでした。施策の種別ごとに担当者をある程度固定する体制を取っていたこともあり、それぞれの経験やナレッジをもとに制作を進める状態が続いていました。 これは月10〜20本という高頻度のリリースを支えるためにスピードを優先してきた結果でもあります。しかし約30名(2026年3月時点)のデザイナーが関わる体制へと成長する中で、この暗黙知に依存した運用では徐々に限界が見え始めていました。 施策数の増加や案件内容の多様化に伴い、従来担当していなかった種類の施策を担当するケースが増え、参照すべき基準や過去事例が見えづらくなってきました。その結果、同じ役割を持つUI要素であっても担当や案件ごとに微細な差分が生じるようになり、それらが積み重なることで開発側の調整コストが増大していきました。 組織規模と施策量の拡大によって従来の運用モデルでは対応しきれなくなったことで、本来注力すべき施策の世界観設計や企画ごとの表現づくりに十分な時間を割くことが難しくなっていました。 プロジェクトの進め方 この課題を解決するために立ち上げたのがデザインガイドラインプロジェクトです。当初はデザインルールを整備することをゴールとしていました。リモート勤務が中心になって以降、職種間のコミュニケーション量も減っていました。そこで各デザイナーチームから担当者を1人選出して定例MTGや専用Slackで連携を取りながら継続的に議論できる体制を整えました。 プロジェクトの始動と進行の中で見えてきた課題 当初はエンジニア側がLPでよく使うコンポーネントをリストアップしてデザイナー側に共有し、デザイナー側がその内容をもとにガイドラインを作成する方針で進めていました。しかし実際に進めてみるとガイドライン作成は想像以上に停滞しました。 進行が停滞した背景には、ガイドライン整備という作業の性質と企画デザイナーの業務特性との間に構造的な問題があったと考えています。 ガイドライン整備は要素の網羅的な洗い出し、揺れの排除、再利用可能な形への収束が必要な作業です。さらに内容の正確さだけではなく公開可能なドキュメントとしての見た目の整合性や網羅性も求められるため、1つのコンポーネントを整えるだけでも想定以上の時間がかかります。 一方で企画デザイナーの日常業務は、案件ごとの世界観や表現を設計する発散型の思考が中心です。この性質の違いから、通常業務と並行してガイドラインをゼロから整備することは構造的にも負荷の高い進め方でした。デザイナーの業務は案件の波に影響されやすく、繁忙期には計画通りに工数を確保することが難しいという企画LP組織の特性もありました。 進め方の改善 こうした構造的な課題を踏まえ、ガイドライン整備という作業の性質に着目して進め方を改善しました。要素の網羅的な洗い出しや仕様の収束といった作業は、実装構造やコンポーネント設計を把握しているエンジニアの視点と相性が良いと判断しました。 そこでエンジニアがコードベースから過去の仕様やユースケースを確認して叩き台を作成し、デザイナーがその内容を精査・調整してガイドラインを発展させていく作業を担う体制へと切り替えました。この体制によりデザイナーは「ゼロから整える作業」ではなく「品質を高める判断」に集中でき、収束型の意思決定が加速しました。結果として進行は大きく改善し役割分担も明確になりました。 またこの進め方に切り替えたことで、エンジニアが普段の開発業務で扱っているコンポーネントや変数に近い概念をFigma上でも扱いやすくなり、コンポーネントやバリアブルなどの機能活用も進みました。その結果ガイドラインは単なるデザインルール集にとどまらず、デザインシステムに近い形へと発展していきました。 ガイドラインの設計 ここからは、実際のガイドライン構成をご紹介します。 ファイル構成 本デザインガイドラインでは、デザイナーが管理しやすいように、ガイドラインとコンポーネントセットを同じFigmaファイル内で管理しています。そのためFigma内の情報量が多く、今後もコンポーネントが継続的に増えていく可能性も高いことから、拡張性を考慮してコンポーネント単位でページを分けて作成しています。またファイル名は「デザイン名 / 実装名」で統一し、デザイナー・エンジニアどちらの視点からも目的の項目にたどり着きやすくしています。 また運用マニュアルとして「デザインガイドライン運用マニュアル」と「LP運用マニュアル」を用意し、ガイドラインの利用ルールやLP制作時のFigma活用方法を明文化しました。 ガイドライン構成 ガイドラインは「コンポーネントの利用方法を伝える」ことを意識した構成になっています。コンポーネント名・構造・変更可能な要素・スタイル・デザインパターンをひとまとまりで参照できるようにしています。 コンポーネント名 基本構造 構造詳細(デザイン変更可能な要素) スタイル デザインパターン 変更可能な要素 企画LPは施策ごとにトーンや世界観が大きく変わるため、表現の自由度を残す必要がありました。一方で1つのコンポーネントの中でも自由に変更可能な箇所やデザインパターンが多数存在するため、FigmaのComponent propertiesだけでは十分に制御しきれませんでした。そのためパターンとして定義できるものはComponent propertiesで管理し、それ以外の自由に変更できる部分についてはガイドライン上で変更可能な要素を明示する形にしました。 コンポーネント利用箇所から変更可能な要素を確認できるプラグインを作成 ガイドライン上で明示するだけではコンポーネントを使うたびにガイドラインを都度見に行く手間がありました。そこでコンポーネントの利用箇所からでも変更可能な要素を確認できるように専用のプラグインも作成しました。 変更可能な要素は画面上で点線のレイヤーを付けて視覚的に示し、右側のアノテーションでも項目を明示してどこが調整可能かひと目で把握できるようにしています。 コンポーネント 各コンポーネントのページには、デザインガイドラインとFigmaコンポーネントを1つのファイル内にまとめています。左側にはコンポーネントの基本構造やSP(スマートフォン)/PCごとのスタイル、基本的な種類といったガイドラインを記載し、右側にはFigmaコンポーネントを配置しています。これにより、デザイナーはガイドラインを確認しながらそのままコンポーネントを利用でき、仕様の確認と実作業を同じファイル内で完結できる構成としました。 ガイドライン整備の結果、利用を開始した2025年7月下旬以降のコンポーネント利用数は継続的に増加していきました。制作現場でもコンポーネント利用が徐々に定着してきており、ガイドラインを整備するだけでなく実際に使いやすい構成や運用にまで落とし込めたことがこの定着につながったと捉えています。 まとめ 本記事では、ZOZOTOWNのLP制作における属人的なデザイン管理の課題に対し「LP向けデザインガイドライン」を構築した取り組みをご紹介しました。 デザインガイドラインを作成することで開発側のコンポーネントとデザインの差分が抑えられ、企画LP開発時の出戻り削減につながりました。 またエンジニアが叩き台を作りデザイナーが精査・発展させるという進め方は、業務特性に合わせた協業モデルの再設計でもあり、暗黙知を言語化しチームで活用できる知識に変えるための知見も得られました。 企画LPのように表現の多様性が求められる領域でも「固定する要素」と「自由に変更できる要素」を明確に区分することで、効率化と表現の最大化を両立しやすい形にできました。この結果、制作の起点が「それぞれの経験やナレッジで作る」から「既存のコンポーネントを活用して表現を作り込む」へと移行できました。デザインガイドライン作成を通して個人最適ではなく組織全体として効率的に制作を進められる土台ができたと考えています。 さいごに 今回の取り組みを通して、企画LP向けのデザインガイドラインを整備できました。一方でガイドラインは作成して終わりではなく、実際の制作現場で継続的に活用され改善されていく状態を作ることが重要だと思います。今後は継続的に改善できる仕組みづくり、さらなるガイドラインの浸透、Figma Makeなどを活用した拡張を進め、制作現場の運用基盤としてさらに発展させていくことが目標です。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは。Developer Engagementブロックの @wiroha です。3月23日(月)に、ZOZOにて中高生女子を対象とした体験イベント「 ZOZOTOWN・WEARを支える技術と働き方を知ろう! 」を開催しました。 これは 公益財団法人山田進太郎D&I財団 が実施する「 Girls Meet STEM 」プログラムの一環です。中高生女子がSTEM(科学・技術・工学・数学)分野で働く人やSTEM分野で学ぶ学生、実際の現場に触れることで、将来の可能性を広げる機会を提供することを目的としています。ZOZOではこの活動の意義に共感し2024年より参画しており、今回は3度目の開催です。 今回は18名の参加者が集まり、オフィスツアー、サービス体験&技術紹介、女性エンジニアとの交流を通じて、ファッションと技術の面白さを体感しました。本記事では、当日の様子をご紹介します。 イベント概要 日時:2026年3月23日(月)13:00~15:30 会場:ZOZO西千葉本社 対象:中学1年生~高校3年生までの戸籍上または性自認が女性の方 定員:20名 www.shinfdn.org オープニング まずは会社紹介や事業紹介により、ZOZOのことを知ってもらう時間を設けました。ZOZOTOWNやWEAR by ZOZO(以下、WEAR)のサービス、計測事業などについて解説することで、この後のサービス体験&技術紹介の内容をより深く理解してもらうことを目指しました。 サービス体験&技術紹介 2つのグループにわかれ、「サービス体験&技術紹介」と「オフィスツアー」を交代で実施しました。「サービス体験&技術紹介」では、ZOZOTOWNのARメイク、フェイスカラー計測ツール「ZOZOGLASS」、WEAR by ZOZOのファッションジャンル診断を体験してもらいました。 AR技術でメイクが施された画面上の自分の顔に驚き、カラフルで見慣れないZOZOGLASSを手に取り笑顔が出るなど、ZOZOの技術を楽しんでいる様子でした。体験した後は各サービスに使用されている技術を紹介し、技術によってファッションが楽しくなることを感じてもらいました。 オフィスツアー こだわりの社屋である、西千葉本社のオフィスツアーを実施しました。メッセージが込められたアートや遊び心のある会議室、絨毯の模様や色使いの工夫など、ZOZOらしいデザインが施されたオフィス内を案内しました。クイズを交えながらの紹介で、参加者の皆さんも考えながら楽しんでいました。 昨年竣工したばかりの会議棟「ZOZOTENT(ゾゾテント)」も案内し、最新のオフィス環境を体験してもらいました。最初は緊張していた参加者も、オフィス内を歩くうちにリラックスできた様子でした。 パネルトーク 次にパネルトークを開催し、新卒1〜2年目の若手女性エンジニアから話を聞きました。学生時代の経験やエンジニアになろうと思ったきっかけ、中学・高校時代の進路選択などについて語ってもらいました。 年齢の近いエンジニアからの話は身近に感じられたようで、熱心に聞き入っていました。転学科した話もあり、タイミングに合わせて進路やキャリアを考えながら、自らアクションすることの大切さを感じてもらえたのではないでしょうか。 質問会 その後は少人数のグループに分かれて参加者からの質問に答える時間を設け、ZOZOの女性エンジニア4名が一緒にお話ししました。 Slido を活用したところ、非常にたくさんの質問が寄せられました。「ZOZOにはどんな職種がありますか?」「エンジニアに文系の人はいますか?」「就活で一番必要だと思ったスキルは何ですか?」など、学習や進路に関する質問に対してエンジニアたちが自身の経験を交えながら丁寧に答えました。 お土産 参加者の皆さんに、ZOZOオリジナルグッズなどをお土産としてお渡ししました。イベントの思い出として楽しんでもらえたら嬉しいです。今回の体験時間に入りきらなかったZOZOMATもお渡ししており、自宅で足の3Dサイズ計測を体験してもらえればと思います。 最後に 参加者の皆さんからは、次のような感想をいただきました。 文理選択のみならず、学部や職業決めの体験談を聞くことができたので、とても参考になりました。 実際に働いている方々が感じていることや、大切にしている考え方などを教えていただき、自分の視野が広がったように感じました。 施設もとても綺麗でとても楽しそうに仕事していて、私もこんなところで働きたいなと思いました。 将来の職についてたくさん不安があったのですが、悩みを沢山聞いていただけて本当に参加してよかったと思いました。 ZOZOはこれまでもさまざまな女性活躍推進のための活動に取り組んできており、今後もこうした機会を提供していきたいと考えています。本イベントにより中高生女子の皆さんがファッションと技術の面白さを感じ、将来の可能性を広げるきっかけになれば幸いです。
はじめに こんにちは、データ・AIシステム本部の冨田です。ファッションコーディネートアプリ「WEAR」において、ユーザーのコーディネート投稿データを分析し、「似合う」を届けるための機能開発を担当しています。 WEARには日々膨大な数のコーディネートが投稿されています。それらを活用して、経営戦略でもある「ワクワクできる『似合う』を届ける」ためには、画像やテキストからファッションに関する特徴を抽出する必要があります。本記事では、リサーチャーとの協業による評価サイクルを構築しながら、プロンプトエンジニアリングのみで特徴抽出の精度目標を達成した事例を紹介します。 背景・課題 独自定義「似合う4大要素」の抽出 現在私たちは、WEARのコーディネートデータから 「似合う」を構成する4大要素 を抽出するプロジェクトを進めています。本システムでは、まずLLMを用いてコーディネートの画像やテキストから言語化された特徴を抽出します。その後、説明可能なルールベースのロジックに入力して最終的な4大要素を判定するというハイブリッドな構成をとっています。この仕組みを正しく機能させるためには、まずは前段となるLLMが「オーバーサイズ」や「丈感」といったファッション特有の曖昧な特徴を正確に抽出する必要があります。 一般的なプロンプトの限界 ファッションの言語化は非常に曖昧です。例えば「オーバーサイズ」といっても、少しゆとりがある程度を指すのか、極端にシルエットが大きいものを指すのか、人によって解釈が異なります。単純に「この画像はオーバーサイズですか?」とLLMに尋ねるだけでは、サービスが求める基準(ZOZOとしての正解)とLLMの出力が乖離してしまい、実用レベルの精度が得られないという課題がありました。 アプローチ(技術選定) 手法の比較検討 LLMの回答精度を向上させる手法として、一般的に以下の3つが検討されます。私たちは開発コスト・運用コスト・データ準備の観点から比較しました。 手法 概要 メリット デメリット 今回の判断 プロンプトエンジニアリング 指示文(Prompt)の工夫のみで精度を上げる 開発・運用コストが最小。即時反映が可能。 モデルの知識外のことは回答できない。 採用 RAG 外部知識を検索してプロンプトに含める 最新情報や独自データに対応できる。 検索システムの構築・運用コストがかかる。 不採用 ファインチューニング 追加データでモデル自体を再学習させる 特定のタスクや出力形式に特化できる。 高品質な大量の学習データと計算コストが必要。 不採用 選定理由 近年、LoRA(Low-Rank Adaptation) 1 などの効率的な手法の普及により、ファインチューニングのハードルは大きく下がりました。それでも、まずはプロンプトエンジニアリングで限界まで性能を引き出し、ベースラインを確立してから次の手法を検討する、というワークフローがベストプラクティスとなっています。 OpenAIの公式ドキュメント内のOptimizing LLM Accuracy 2 では、モデルの最適化を「Context(知識)」と「Behavior(振る舞い)」の2軸で定義しています。まずはプロンプトでベースラインを測定します。その上で、独自の知識が不足していればRAGを、特定の振る舞いや出力形式の徹底が必要であればファインチューニングを選択する、というアプローチです。また、多くの場合、プロンプトエンジニアリングだけで本番レベルの精度に到達できるという旨も記載されています。 今回のタスクにおいても、ファッションの一般的な知識自体はLLMが既に学習済みであり、最大の課題は「ZOZO独自の定義へのすり合わせ」にありました。そのため、いきなりコストや運用負荷のかかる手法に移行するのではなく、まずはプロンプトを徹底的に磨き込むことにしました。 プロンプト改善・評価サイクル Google Cloudの「プロンプト設計の戦略」ドキュメント 3 より、プロンプト設計は反復的なプロセスであるとされており、継続的なテストと評価の重要性が説かれています。私たちはこれに則り、本格的なプロンプトチューニングへ着手する前に、以下のプロセスで評価サイクルを構築しました。 1. 開発用データセットの作成 エンジニアがプロンプト改善を試行錯誤するための正解データを用意します。社内のリサーチャー(ドメインエキスパート)に依頼し、WEARに投稿されたコーディネートの中から評価対象の特徴を持つ画像を探してラベルを作成してもらいました。 今回は100項目以上の特徴抽出が必要になるため、全件に対して十分なアノテーションを用意することは工数面で非現実的でした。そこで、本施策では各特徴量につき10件という最小限のデータで精度を検証するアプローチを採用しました。少数のデータでは特定のアイテムへの過学習(汎化できているか)が課題になります。これについては後述する定性評価にて、後段のルールベースを通した最終結果で担保する割り切ったアプローチをとりました。 2. プロンプト改善 開発用の評価データセットがあるおかげで、エンジニアは「なんとなく良さそう」といった感覚値ではなく、目標とした定量指標に向かってプロンプトを改善できるようになりました。今回は正解率70%を目標に設定しています。もちろん100%が理想ですが、開発リソースやリリースまでの期間には限りがあります。そこで、「抽出した特徴でコーディネート検索を行った際、結果として並んだ10枚のうち、何枚までならノイズが混ざっても体験を損なわないか」というシナリオをもとにプロジェクト内で議論しました。その結果、リリースに向けた開発コストとユーザー体験のバランスをとる現実的な落とし所として、この70%という目標値を決定しました。このように明確な基準と評価データが揃ったことで、エンジニアが手元で自律的かつ高速にチューニングを回すことができました。 3. ルールベースのロジックを通した最終出力による定性評価 開発用データセットに対する過学習を防ぎ、本番環境での網羅性を確認するために定性評価します。本来はLLMが抽出した特徴を直接評価したいところですが、無作為に収集した画像に対する抽出結果では出現頻度の低い特徴をうまく引き当てられません。また、評価する特徴が多過ぎるため、効率的な評価が困難です。そこで、後段のルールベースのロジックを通した結果のラベルを使って定性評価することにしました。開発段階でWEAR上の全データを推論すると時間とコストがかかり過ぎてしまうため、ファッションの季節性を網羅するように評価用データセットを作成しました。評価用データセットに特徴抽出とルールベースの判定をしてラベルを付与しました。最終的に付与されたラベルごとに300枚をサンプリングし、リサーチャーによる定性評価(こちらも目標正解率70%)をしました。 評価サイクルで得られた効果と結果 エラー分析による「曖昧さ」の解消 定量評価が可能になったことで、冒頭で触れた「ファッションの曖昧さ」に対して、「具体的に何ができていないのか」が可視化されるようになりました。例えば、評価結果のFalse Positive(誤検知)を分析した結果、以下のような原因が判明しました。 「厚底」の特徴:LLMの持つ一般的な厚底の基準と、ZOZOが求める基準にズレがある。 「柄や装飾」の特徴:服のシワや影を、柄として誤認識してしまっている。 原因が具体的に特定できたことで、リサーチャーと「どうすればLLMに伝わるか」を擦り合わせることが容易になりました。結果として、単純な2値(Yes/No)で判定させるのではなく、「度合いを複数のクラスに分類させてから判定する」といったプロンプトの改善に繋げられました。こうした改善の積み重ねにより、目標の正解率70%を達成しました。 適切なモデル選択 今回のプロジェクトでは非常に多くの特徴量を抽出するため、色や柄などのファッション特徴のカテゴリごとにプロンプトを分けています。 タスクの難易度に応じて、より上位のモデルを採用したものもあれば、逆に軽量なモデルへ落としても精度を維持できたものもありました。定量評価によって「どこまでモデルを落としても許容できるか」が数値化されたことで、システム全体での推論コストや処理時間の最適化を安全に進められました。 今回の手法の確からしさ 全ての特徴の開発とラベルの評価が完了したあとに、4か月分のデータセットに対してラベルの付与率などを分析しました。また、サービスリリース前にWEAR上のコーディネート画像全件に対しても同様に推論・分析し、付与率に大きな差がないことを確認しました。非常に少ないアノテーション画像からのスタートでしたが、特定の期間やアイテムに特化した調整(過学習)にはなっておらず、本番環境のデータに対しても適切に汎化できていることが確認できました。 課題と展望 今後の展望:LLM-as-a-judgeの導入 一方で、評価用データセットの作成にはリサーチャーの人手コストがかかるという課題も残りました。今後は、作成した正解データと評価基準を用いて別のLLMに評価担当を任せるLLM-as-a-judgeの導入を検討しています。LLMによる一次評価で大まかな傾向を掴み、判断が分かれる際どいケースのみリサーチャーが確認するフローにすることで、評価コストを下げつつ、より高速な改善サイクルを実現できます。 まとめ 本記事では、WEARの機能開発におけるLLM活用事例として、RAGやファインチューニングを使わずに高精度な特徴抽出を実現したプロセスをご紹介しました。 ZOZOでは、ファッションの曖昧な感性を技術で解き明かし、ユーザーに新しい体験を届けるエンジニアを募集しています。ご興味のある方は、ぜひ採用ページをご覧ください。 corp.zozo.com 参考文献 Hu, E. J., et al. (2021). LoRA: Low-Rank Adaptation of Large Language Models. ↩ Optimizing LLM Accuracy ↩ プロンプト戦略の概要 ↩
はじめに こんにちは、WEAR開発部バックエンドブロックの小山です。普段は弊社サービスである WEAR のバックエンド開発を担当しています。 WEARではハイブリッド検索などの新たな検索体験の実現を目指しています。その実現に必要な ハイブリッド検索 はOpenSearch 2.11で導入された機能です。Elasticsearch 7.10.2では利用できないため、Amazon OpenSearch Service上のエンジンをOpenSearch 2.11.0以上へ移行する必要がありました。今回はOpenSearch 2系の最新バージョンだった2.19.0を採用しました。本記事では、この移行にあたり対応したSearchkickの導入、ダブルライト戦略によるインデクシング移行、カナリアリリースによる段階的トラフィック切り替えについてご紹介します。 目次 はじめに 目次 抱えていた課題 Elasticsearch 7.10.2の限界 既存のアーキテクチャ 課題を解決したアプローチ 1. Searchkickとopensearch-rubyへの移行 elasticsearch-modelからSearchkickへ elasticsearchからopensearch-rubyへ 既存Searchableとの並存 2. インデクシングのダブルライト戦略 embulk-outputの変更 RakeタスクとDigdagワークフローの追加 3. クエリ種別ごとの動作確認 確認の目的と方針 確認対象の抽出方法 確認したクエリ種別 確認方法 4. 負荷試験 試験条件 試験結果 5. カナリアリリースによる段階的トラフィック移行 リリーススケジュール 各段階での確認項目 確認結果 効果と得られた知見 移行後のアーキテクチャ Searchkickとopensearch-rubyへの移行による保守性向上 並行稼働時のインデクサー移行方法 カナリアリリースの有効性 おわりに 抱えていた課題 Elasticsearch 7.10.2の限界 WEARではコーディネートや動画、メイクの投稿検索にAmazon OpenSearch Service上でElasticsearch 7.10.2を利用していました。しかし、以下の課題がありました。 新機能の利用不可:WEARではハイブリッド検索などの新たな検索体験を計画していたが、Elasticsearch 7.10.2はハイブリッド検索に対応しておらず、実現できない状態 サポートの先行き不透明:Elasticsearch 7.10.2は、Amazon OpenSearch Serviceで提供される最終のオープンソースElasticsearchバージョン。今後の新機能追加やセキュリティパッチの提供が見込めない状態。Elasticsearch 7.1〜7.8の標準サポートは2025年11月に終了しており、7.10.2も同様のサポート終了が予想される状態。AWS側でもOpenSearchエンジンへの移行を推奨 ライブラリのメンテナンス性: elasticsearch gem 7.14.0以降ではAmazon OpenSearch Service上のElasticsearchへ接続不可。gemのバージョンを7.13.3に固定せざるを得ず、アップデートができない状態 既存のアーキテクチャ WEARの検索基盤は、以下のシステム構成で運用していました。 検索機能: elasticsearch-model gemを利用し、検索メソッドを提供。内部では elasticsearch gemが提供する Elasticsearch::Client を通じてOpenSearch Serviceと通信 マッピング定義: elasticsearch-model gemを利用し、モデルにマッピング定義を記述 インデックス操作: elasticsearch gemを利用し、Rakeタスクによるインデックス作成、エイリアス切り替え、旧インデックス削除、ドキュメント削除 インデクシング:トラフィックを考慮し、レコード更新ごとではなくDigdagワークフローと Embulk による定時バッチ(日次の洗い替えと差分更新)でインデクシング 課題を解決したアプローチ 今回の移行では、既存ドメインのインプレースアップグレードではなく、OpenSearch 2.19.0の新規ドメインを作成し、エンドポイントを段階的に切り替える方法を採用しました。その理由は以下の通りです。 インプレースアップグレードでは、Elasticsearch 7.10.2からOpenSearch 2.19.0へ直接移行できず、 OpenSearch 1.xを経由する必要がある elasticsearch-model / elasticsearch から searchkick / opensearch-ruby へのgem移行が必要であり、アプリケーションコードに破壊的変更が生じる 検索基盤は影響範囲が大きいため、カナリアリリースで段階的にリリースしたい これらを踏まえ、Elasticsearchをダウンタイムなく移行させるために以下のアプローチで段階的に進めました。 Searchkickとopensearch-rubyへの移行 インデクシングのダブルライト戦略 クエリ種別ごとの動作確認 負荷試験 カナリアリリースによる段階的トラフィック移行 1. Searchkickとopensearch-rubyへの移行 移行前後のgemの対応関係は以下の通りです。 責務 Elasticsearch利用時 OpenSearch移行後 検索機能 elasticsearch-model (内部で elasticsearch を利用) searchkick (内部で opensearch-ruby を利用) マッピング定義 elasticsearch-model searchkick インデックス操作 elasticsearch 直接利用 opensearch-ruby 直接利用 elasticsearch-modelからSearchkickへ 検索機能とマッピング定義については、既存の elasticsearch-model の代わりに、 searchkick に移行しました。Searchkickを選定した理由は以下の通りです。 OpenSearchを公式にサポートしている リポジトリが継続的にメンテナンスされている nested型への対応など、 elasticsearch-model との互換性がある reindex時のアトミックなエイリアス切り替えが組み込まれているほか、ハイブリッド検索やセマンティック検索にも対応しており、高度な機能を備えている elasticsearchからopensearch-rubyへ インデックス操作のRakeタスクでは、 elasticsearch を使用していました。OpenSearch移行に伴い、これを opensearch-ruby に置き換えました。 - require 'elasticsearch' - client = Elasticsearch::Client.new(client_options) + require 'opensearch-ruby' + client = OpenSearch::Client.new(client_options) client.indices.update_aliases(...) client.indices.delete(...) opensearch-ruby は elasticsearch とAPIの互換性が高いため、クライアントの初期化部分とエラークラスの変更で、既存のインデックス操作ロジックをそのまま利用できました。 唯一の例外がインデックス作成タスクで、ここではSearchkick経由でマッピング定義を取得して作成しています。 task :create_index , [ :index_name ] => :environment do |_, args| index_class = index_class_name(args[ :index_name ]).singularize.capitalize.constantize index = Searchkick :: Index .new(args[ :index_name ]) model_config = index_class.search_index.index_options # Searchkickからマッピング取得 index.create(model_config) # Searchkick経由で作成 end このように、マッピング定義はSearchkickに一元化しつつ、その他のインデックス操作は opensearch-ruby を直接使用する構成としました。 既存Searchableとの並存 WEARでは、モデルごとに *Searchable というconcernを定義し、 elasticsearch-model を利用した検索用のデータ定義とマッピング定義を集約していました。 移行期間中は、Elasticsearchを利用するサーバーとOpenSearchを利用するサーバーを並行稼働させる必要がありました。そこで、モデルごとに *OpensearchSearchable concernを新設し、既存の *Searchable と並存させる構成をとりました。 既存の *Searchable はElasticsearch用のconcernです。 # 既存: Elasticsearch用 module Searchable extend ActiveSupport :: Concern # elasticsearch-model を利用したデータ定義とマッピング定義 end 新設した *OpensearchSearchable はOpenSearch用のconcernです。 # 新規: OpenSearch用 module OpensearchSearchable extend ActiveSupport :: Concern included do searchkick index_name : Rails .configuration.x.application[ :opensearch ][ :index_name ], settings : Rails .configuration.x.application[ :opensearch ][ :settings ], callbacks : false , merge_mappings : true , mappings : search_mappings def search_data # searchkick を利用したデータ定義 end end module ClassMethods def search_mappings # searchkick を利用したマッピング定義 end end end merge_mappings: true を指定することで、独自に定義したマッピングをSearchkickの自動生成マッピングにマージしています。 callbacks: false を指定することで、Searchkickの自動インデクシングを無効化し、既存のEmbulkによるインデクシングとの競合を防いでいます。 2. インデクシングのダブルライト戦略 移行期間中、ElasticsearchとOpenSearchの両方にデータを投入するダブルライトを実施しました。WEARのインデクシングは日次バッチによる洗い替え方式のため、ダブルライトを開始した時点で既存データも含めてOpenSearchに自動で同期されます。そのため、既存データの移行作業を別途行う必要はありませんでした。 embulk-outputの変更 前述の通り、既存の構成ではEmbulkを介して、BigQueryからデータを取得してElasticsearchにインデクシングしていました。インデクシング時のBigQueryのクエリコストが高額なため、OpenSearchにもインデクシングを行う際に単純にジョブを複製してしまうと、費用が2重に掛かってしまうという課題がありました。 そこで、embulk-outputの出力先をElasticsearchとOpenSearchの両方に向けることで、SQLの実行は一度だけで双方にデータを転送できるようにしました。 移行前はElasticsearchのみに出力していました。 # Elasticsearchへのインデクシング時 out : type : elasticsearch mode : insert nodes : - { host : {{ elasticsearch_host }} , port : {{ elasticsearch_port }}} index : {{ elasticsearch_index }} { % Elasticsearchの設定値 % } ダブルライト時は type: multi を使い、ElasticsearchとOpenSearchの両方に出力しました。 # ElasticsearchとOpenSearchにダブルライトするインデクシング時 out : type : multi outputs : - type : elasticsearch mode : insert nodes : - { host : {{ elasticsearch_host }} , port : {{ elasticsearch_port }}} index : {{ elasticsearch_index }} { % Elasticsearchの設定値 % } - type : elasticsearch mode : insert nodes : - { host : {{ opensearch_host }} , port : {{ opensearch_port }}} index : {{ opensearch_index }} { % OpenSearchの設定値 % } ダブルライトのために embulk-output-multi を新たに導入し、複数出力先への分岐を実現しました。OpenSearch側の出力も type: elasticsearch を指定しています。 embulk-output-elasticsearch はOpenSearchとのAPI互換性により、そのままOpenSearchへの出力にも利用できました。 RakeタスクとDigdagワークフローの追加 OpenSearch向けのインデックス操作のRakeタスクとDigdagワークフローを作成し、OpenSearchに対しても実行できるようにしました。 # 既存のElasticsearchのインデックス作成 +create_index_elasticsearch: sh>: ... rails "elasticsearch:create_index[${index_name}]" # 追加したOpenSearchのインデックス作成 +create_index_opensearch: sh>: ... rails "opensearch:create_index[${index_name}]" 3. クエリ種別ごとの動作確認 OpenSearch移行後にすべてのクエリ種別が正常に動作するかをQA環境で確認しました。 確認の目的と方針 Elasticsearchに送信されるクエリの種別ごとに、OpenSearch上でも同等の結果が返ることを確認しました。クエリ種別が重複するエンドポイントは確認対象外とし、効率的に網羅性を担保しました。 確認対象の抽出方法 確認対象の抽出は以下の手順で行いました。 対象エンドポイントの洗い出し:リポジトリ内でElasticsearchのQueryクラスを呼び出している箇所をリストアップ WEAR Webの対象画面の特定:Webマスタ仕様書から対象エンドポイントが使用されている画面を確認 クエリの特定:APIのリクエストパラメーターから生成されるOpenSearchのクエリJSONを特定し、使用されているクエリ種別を分類 確認したクエリ種別 以下のクエリ種別を対象に、WEAR iOS・Android・Webの各プラットフォームで動作確認を実施しました。 分類 クエリ種別 検索クエリ term 、 terms 、 range 、 nested 、 bool ( filter / must_not / must / should )、 function_score 、 exists ソート sort ページング from 、 size グループ化 collapse 複合検索 msearch 確認方法 WEAR iOS・Android・Webの各プラットフォームで、以下の方法で確認しました。また、対応するRSpecテストを実行し、OpenSearchに対するクエリが正常に動作することはCI上で確認しています。 WEAR iOS・Android:QA環境のAPIに対してcurlコマンドでリクエストを送信し、レスポンスを確認。 WEAR Web:ブラウザ上で対象画面を操作し、APIレスポンスと画面表示を目視確認。 すべてのクエリ種別で正常な動作を確認し、負荷試験に進みました。 4. 負荷試験 本番リリース前に、OpenSearchクラスターがElasticsearch利用時と同等のリクエスト量を処理できるかを確認するため、QA環境で負荷試験を実施しました。 試験条件 QA環境のOpenSearchクラスターを本番環境のElasticsearchと同等のスペックに設定 検索エンドポイントのRedisキャッシュを無効化し、OpenSearchへの直接的な負荷を計測 k6を用いて、各検索エンドポイントに対して本番のピーク帯のMAX rps相当のリクエストを6時間継続 試験結果 レイテンシ :Datadog APMで各検索エンドポイントのp99レイテンシを直近1か月の平均と比較した結果、OpenSearchがボトルネックとなるレイテンシ劣化は観測されなかった エラー :Datadog APMで各検索エンドポイントを確認した結果、OpenSearch起因のエラーは発生しなかった クラスターメトリクス :本番のピーク帯MAX値相当のリクエストを6時間継続した。CPUUtilizationはリクエスト量に対して許容範囲内、JVMMemoryPressureは本番環境と同程度であり、各種メトリクスに大きな影響はなかった この結果をもとに、カナリアリリースによる段階的な本番投入を判断しました。 5. カナリアリリースによる段階的トラフィック移行 本番リリースでは、カナリアリリースによって段階的にトラフィックを移行しました。 リリーススケジュール 日時 内容 2025/9/30 13:00 canary podの作成、APIの正常確認、1%リリース 2025/9/30 17:00 10%リリース 2025/10/1 14:00 50%リリース 2025/10/2 13:30 100%リリース 2025/10/2〜10/6 正常性の継続監視 各段階での確認項目 各段階で以下の項目を確認し、問題がなければ次の段階に進みました。 OpenSearchのレイテンシ比較とエラー確認:Datadog APMでOpenSearchとElasticsearchのレイテンシを比較し、劣化がないことを確認。OpenSearchのエラーがないことを確認。 各検索エンドポイントのレイテンシ比較とエラー確認:Datadog APMで各検索エンドポイントのレイテンシを比較し、劣化がないことを確認。OpenSearch起因のエラーがないことを確認。 クラスターメトリクス:SearchLatency、IndexingLatency、CPUUtilization、JVMMemoryPressureを監視し、劣化がないことを確認。 インデックスの整合性:ElasticsearchとOpenSearchのドキュメント件数に差異がないことを確認。 確認結果 OpenSearchでレイテンシが低い傾向を確認した(平均・最小・最大いずれもOpenSearchの方が高速) OpenSearch起因のエラーが発生しなかった OpenSearchでJVMMemoryPressureがやや高い傾向にあったが、MAXでも60%未満であり問題なかった CPUUtilizationはOpenSearchの方が低い傾向だった 100%リリース後の監視でも劣化が見られず、移行完了を判断した 効果と得られた知見 移行後のアーキテクチャ 移行後の検索基盤は、以下のシステム構成になりました。 検索機能: searchkick gemを利用し、検索メソッドを提供。内部では opensearch-ruby gemが提供する OpenSearch::Client を通じてOpenSearch Serviceと通信 マッピング定義: searchkick gemを利用し、モデルにマッピング定義を記述 インデックス操作: opensearch-ruby gemを利用し、Rakeタスクによるインデックス作成、エイリアス切り替え、旧インデックス削除、ドキュメント削除 インデクシング:既存のDigdagワークフローと Embulk による定時バッチ(日次の洗い替えと差分更新)でのインデクシングを継続 Searchkickとopensearch-rubyへの移行による保守性向上 elasticsearch-model から searchkick 、 elasticsearch から opensearch-ruby に移行し、以下の効果と知見がありました。 OpenSearchの将来的なバージョンアップへの追随が容易になった reindex処理のアトミックなエイリアス切り替えが組み込みで利用可能になった ハイブリッド検索の機能が利用可能になった opensearch-ruby はAPI互換性が高く、Rakeタスクの移行コストが低かった 並行稼働時のインデクサー移行方法 ダブルライト戦略により、以下のメリットがありました。 ElasticsearchとOpenSearchを並行稼働させることで、いつでも切り戻し可能な状態を維持 Embulkを利用した既存のインデクシングパイプラインを最小限の変更で拡張 移行時のクエリコスト増大を防止 Digdagワークフロー層での制御により、アプリケーションコードへの影響を最小化 カナリアリリースの有効性 段階的なトラフィック移行により、以下の知見が得られました。 1%リリースと10%リリースで、JVMMemoryPressureの変動が大きく見られた。これは、リリース後の低トラフィック時にキャッシュヒット率が低いことに起因する可能性が高く、50%リリース以降は安定した。 検索基盤のような影響範囲の大きいミドルウェアの移行にはカナリアリリースが有効であることを実感した。 おわりに 本記事ではWEARにおけるElasticsearch 7.10.2からOpenSearch 2.19.0への移行プロセスを紹介しました。同様の移行を検討している方の参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、YSHP部の三上です。Yahoo!ショッピングに出店しているZOZOTOWNの店舗である ZOZOTOWN Yahoo!店 のバックエンド開発を担当しています。私は2023年10月、社内公募を経てYSHP部へ異動しました。それまでは長らくビジネス部門に所属しており、開発は未経験でした。ZOZOTOWN Yahoo!店に携わるのも初めてで、APIという言葉の意味も曖昧な状態からのスタートでした。 そんな中、2025年9月末にジョインしたのが、ZOZOTOWN Yahoo!店へのギフトラッピング機能導入プロジェクトです。この取り組みは2021年頃から構想はあったものの、Yahoo!ショッピングとZOZOTOWNの仕様差分が大きく、実現に至っていませんでした。私がジョインした時点では、仕様の多くが確定していない状態でした。一方で、クリスマス商戦前にリリースするという目標だけは明確に決まっており、開発側のメイン窓口として推進を担うことになりました。 この記事で伝えたいこと 本記事では、以下の3点についてお伝えします。 仕様が異なるシステム統合における差分整理と責任分界の設計方法 未確定事項の多いプロジェクトの推進方法 開発未経験からのキャリアチェンジでの学び 目次 はじめに この記事で伝えたいこと 目次 ギフト導入までに取り組んだこと まず着手したのは「実装」ではなく「未確定事項の可視化」 仕様差分をどう吸収したか ギフト種別の違い 包装選択肢の違い ギフト利用NG条件の違い 既存アーキテクチャに沿った拡張 本番注文データでの実運用テスト 振り返り クリスマス前のリリースと反応 仕様差分のある統合で重要だったこと キャリアチェンジ直後でも推進できた理由 おわりに ギフト導入までに取り組んだこと まず着手したのは「実装」ではなく「未確定事項の可視化」 私がプロジェクトにジョインした時点では、クリスマス商戦前のリリースというゴールは明確でした。一方で、仕様の8割近くは未確定のまま、詳細はほとんど決まっていない状態でした。社内にQA表や議事録はありましたが、以下のような課題が散在していました。 一度議題に上がったものの結論を明文化できていない事項 一部で合意しているが全体として整合が取れていない内容 社内外で認識が揃っているかどうか確信が持てない論点 そのため、最初に着手したのは実装ではなく、ドキュメントやSlackでのやりとりを横断的に確認し、未確定事項と仕様差分を一覧化することでした。 何が決まっているのか 何が決まっていないのか どこに認識差異が生まれそうか を1つずつ整理しました。社内だけでも10〜20件程度の未確定事項があり、それらをもとに社内外のMTGを設定し、「最終的にどのレイヤーで何を制御するのか」という責任範囲を明確にしていきました。実装へ進む前に、制御方針と関係者の認識を揃えることを優先しました。 仕様差分をどう吸収したか 本プロジェクトにおいて最大のハードルの1つが、Yahoo!ショッピングとZOZOTOWNの仕様差分です。両システム間では、ギフト機能の仕様や前提となる設計思想が大きく異なっており、単純な横展開ができるものではありませんでした。 代表的な差分や特に判断が必要だったポイントをいくつか紹介します。 項目 Yahoo!ショッピング ZOZOTOWN 今回の対応 ギフト種別 通常ギフト/ソーシャルギフト ギフトラッピング ZOZOTOWN側の構造は変更せずYahoo!ショッピング側で選択肢を制御 包装選択 「指定なし」あり 必須 API連携時に必ず包装が設定されるよう制御 利用NG条件 独自の制御ロジック 対応上限数・在庫種別等の複合条件 APIレスポンスで可否を返却し責任を分界 ギフト種別の違い Yahoo!ショッピングには「通常ギフト」と「ソーシャルギフト」の2種類あります。ソーシャルギフトでは、購入者がURLを共有し、受取人がお届け先を入力する仕組みを提供しています。一方で、ZOZOTOWNにはこの仕組みがなく、ギフトの前提構造が異なる状態でした。 この差分に対しては、ZOZOTOWN側のデータ構造は変更せず、Yahoo!ショッピング側で選択肢を制御する方針としました。ZOZOTOWN側に新たな概念を持ち込むと既存の注文フローや配送処理への影響範囲が大きいため、既存構造の中で成立させることを優先した判断です。 包装選択肢の違い Yahoo!ショッピングでは包装に「指定なし」を選択できますが、ZOZOTOWNではギフト注文時に包装指定が必須です。この違いは単なるUIの差ではなく注文データの構造にも影響するため、ZOZOTOWNのデータ構造に落とし込む必要がありました。 そのため、「指定なし」をそのまま連携せずに、API連携時に必ず包装が設定されるよう制御する設計としました。UIの見え方ではなく、データ連携時にどう変換するかという観点で解決しました。 ギフト利用NG条件の違い 両社では、ギフト利用可否に関する制約も異なっていました。例えばZOZOTOWNでは、以下のような条件でギフト利用可否を制御しています。 発送拠点での1日のギフト上限数到達 外部在庫の商品を含む注文 予約商品を含む注文 ギフト利用不可の商品を含む注文 また、ギフトを選択した場合には代引き・置き配との併用が不可になるほか、即日配送も一部エリアを除き利用が制限されるなど、配送・決済オプションにも影響があります。Yahoo!ショッピング側にも独自の制御ロジックはありますが、今回のプロジェクトではZOZOTOWN側の制約を確実に担保することが前提でした。そのため、これらの条件をどのように両社で役割分担しながら制御するのかを決める必要がありました。 ZOZOTOWNのギフト利用NG条件は、発送拠点の対応上限数や在庫種別など内部状況に依存します。そのため、ギフト選択有無に関わらず、ZOZOTOWN側から常にギフト可否(OK/NG)をAPIレスポンスで返却する方針を取りました。また、即日配送・置き配・代引きといった配送・決済オプションについても、ギフト設定有無に応じてレスポンスを切り分ける設計としました。一方で、システム間の責任分界の観点から、Yahoo!ショッピング側で完結できる制御についてはYahoo!ショッピング側へ委ねる形としています。 既存アーキテクチャに沿った拡張 仕様差分を吸収するためには、API設計だけでなく、商品情報の連携にも対応が必要でした。 ZOZOTOWN Yahoo!店では、商品情報の連携にDBトリガーを用いた既存の仕組みがあります。対象テーブルのカラムに更新が走ると、DBトリガーがそれを検知してログテーブルに商品IDを書き込みます。既存のバッチ処理がこのログテーブルを参照し、Yahoo!ショッピング連携用のCSVを生成・FTP連携する、という流れです。今回のギフト導入では、この既存フローを2点拡張しました。 1つ目は、トリガーの追加です。商品情報テーブルのギフトNGフラグが変更された際に、ログテーブルへ書き込まれるようトリガーを新設しました。これにより、商品単位のギフト可否が変わったタイミングで、自動的に連携対象としてキューへ入る仕組みとなります。 2つ目は、CSV出力項目の追加です。既存のバッチ処理が生成するCSVに、ギフト可否を示す項目を追加しました。この項目は、ギフトNGフラグやギフト対象カテゴリの情報をもとに「対象/対象外」を判定し、Yahoo!ショッピング側に連携します。 いずれも新たな連携の仕組みを作るのではなく、既存のトリガー・バッチ処理の延長線上で対応しています。実績のあるフローに乗せることで、影響範囲を最小限に抑えることを意図しました。 本番注文データでの実運用テスト リリース前には、本番注文データをギフト扱いに変更し、実際の運用フローが回るかを確認しました。ギフトラッピングの実作業を管轄するZOZOBASEやお客様対応を担うCSも巻き込み、実運用に近い形で検証しました。検証を通じて、ZOZOTOWN Yahoo!店の注文では、ZOZOTOWNで利用できる一部機能(梱包サイズ超過時にZOZOBASEからCSへ引き継ぐ機能)を利用できないことが判明しました。この機能はZOZOBASEで使用されるハンディ端末に依存しており、私は実機を扱った経験もありませんでした。 そこで、関連システムのソースコードを追い、仕様を読み解くところから始めました。まずHTMLテンプレートの表示制御を確認し、条件分岐によってZOZOTOWNの注文でのみ「ギフト資材超過」の機能を利用できる仕組みに気づきました。次にサーバーサイドのコードでSQL文を追い、この機能が利用された場合にDBへどのような値が書き込まれるかを特定しました。 この仕様理解をもとに、ZOZOTOWN Yahoo!店でも同じ機能を利用できるよう、関係部署と連携して修正しました。結果的に、リリース前に運用上の課題を解消できました。 振り返り クリスマス前のリリースと反応 最終的に、本機能は2025年12月10日にリリースできました。クリスマス商戦前という目標に対し、余裕を持ったスケジュールでのサービスインとなりました。本格的な訴求前の段階でも、ギフト注文は順調に発生し、一定のニーズがあることを確認できました。長年構想止まりだった取り組みを、実際の売上につなげられたことは大きな成果だったと感じています。9月末のアサインから約2か月半という期間は、決して余裕のあるスケジュールではありませんでした。それでも予定通りにリリースできたのは、序盤に未確定事項を解消したことで、後半の開発・テストに集中できたからだと振り返っています。 仕様差分のある統合で重要だったこと 今回のプロジェクトを通じて強く感じたのは、実装よりも前の整理こそが統合の成否を分けるということです。異なる仕様を持つシステム同士をつなぐ場合、以下が重要になります。 表示上の違いだけに着目するのではなく、データ構造や制御レイヤーの差分を整理すること 最終的な制御を担うレイヤーを明確にすること 一方のシステムに過度な責務を集中させず、役割を分割すること 今回も、ソーシャルギフトの扱い、包装「指定なし」の吸収、制約に関する責任分界など、すべてにおいて「既存構造を壊さず、どこで整合を取るか」という判断が求められました。仕様差分は避けられませんが、構造と責任を整理すれば前に進める、という実感を得ることができました。 キャリアチェンジ直後でも推進できた理由 開発未経験で異動した私にとって、今回の案件はこれまでで最も規模の大きなプロジェクトでした。実装そのものは外部パートナーの方にお願いしていますが、キャリアチェンジ直後でもプロジェクトを前に進められたのは、以下を徹底したからだと考えています。 未確定事項を放置せず、可視化すること 認識が揃っているかを細かく確認すること 合意事項を文章として残し、曖昧さを減らすこと 技術力だけでなく、課題整理力や調整力といったスキルも、設計や推進の一部であると今回あらためて実感しました。 おわりに 仕様が異なるシステム同士をつなぐことは、単純な機能追加より難易度が高い場合もあります。しかし、構造を整理して責任を明確にし、1つずつ前提を揃えていけば、前に進めることもまた事実です。今回の取り組みが、仕様差分や責任分界に悩むプロジェクトの参考になれば幸いです。 また、技術力そのものに自信がなくても、整理する力や問い続ける姿勢は、プロジェクトを推進する大きな力になります。同じようにキャリアチェンジ直後で不安を抱えている方の後押しにもなれば嬉しく思います。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、商品基盤部の杉浦、小原、寺嶋です。普段はZOZOTOWNのお気に入り基盤・商品レビュー基盤といった商品サブドメインを担当しています。 私たちのチームでは運用コスト削減を目的として、お気に入りデータベースをオンプレミスのSQL ServerからAWS Aurora MySQLへの移行に取り組んでいます。お気に入りデータは数十億レコードに及び、移行中もデータが増え続けるためデータの静止点が作れないという課題がありました。本記事では、この大規模データ移行における初期移行の取り組みと、Embulkを用いた差分同期について紹介します。 なお、新規データの書き込みを担保するダブルライト戦略については 前回の記事 で紹介しています。あわせてご覧ください。 目次 はじめに 目次 お気に入りリプレイスの概要 技術スタックの老朽化 オンプレミスSQL Serverの運用限界 背景・課題 初期移行 制約と課題 検証と最適化 本番移行の結果 得られた学び Embulkによる差分同期 ジョブ設計 ソースDBへの負荷制御 データ整合性の担保 設定管理とチューニング まとめ お気に入りリプレイスの概要 ZOZOTOWNのお気に入り機能は、会員が興味のある商品・ブランド・ショップを登録し、お気に入り一覧から確認できる機能です。まず、ユーザー種別として 会員 と ゲスト会員 の2種類が存在し、それぞれ独立したテーブルで管理されています。お気に入り登録の対象も 商品・ブランド・ショップ の3種類があり、ユーザー種別との掛け合わせにより、合計6パターンのテーブルが移行対象となります。さらに、 過去に削除されたお気に入りの履歴(アーカイブデータ) も保持されており、これらを含めると移行対象のテーブルは多岐に及びます。テーブルによってレコード数は数千万レコードから数十億レコードまで幅があり、合計すると数十億レコード規模のデータ移行となりました。 この構成は長年にわたりZOZOTOWNを支えてきましたが、以下のような課題を抱えていました。 技術スタックの老朽化 ZOZOTOWNは2004年の開始当初からClassic ASP(VBScript)とSQL Serverのストアドプロシージャでビジネスロジックを実装してきました。しかし、VBScriptは開発元のMicrosoftも積極的に開発しておらず、クラウドベンダーのSDKが提供されていないなど技術的な制約が大きくなっていました。こうした背景からZOZOTOWN全体で リプレイスプロジェクト が進められており、お気に入り機能もその一環としてマイクロサービスへの刷新に取り組んでいます。 オンプレミスSQL Serverの運用限界 ZOZOTOWNは運営開始から10年以上にわたりオンプレミス環境でシステムを拡大してきましたが、スケーラビリティや保守コストの面で課題を抱えていました。2017年より ストラングラーフィグパターンによる段階的なマイクロサービス移行 が進められています。お気に入り機能のデータベースもその一環として、オンプレミスのSQL ServerからAWS上のAurora MySQLへの移行が必要でした。しかし、以下の制約がありました。 Read/Writeが常時発生しており、 システム停止を伴う移行は不可能 書き込んでから読み取れるまでの許容タイムラグが短く、 レプリケーション方式では要件を満たせない オンプレミスDBへの設定変更が必要なマネージドサービス(AWS DMS等)は、 他機能への影響を考慮し使用を見送り お気に入りデータが膨大なため、 インデックス設定などのチューニングにも数時間を要する状態 これらの課題を踏まえ、移行方式を設計し技術検証しました。移行戦略の全体像は以下の3フェーズで構成されています。 フェーズ1 : SQL Server単体での運用(移行前) フェーズ2 : SQL ServerとAurora MySQLのデュアル運用(移行期間) フェーズ3 : Aurora MySQL単体での運用(移行完了) フェーズ2におけるダブルライトの仕組みやフェーズ切り替えの実装については 前回の記事 で紹介しています。本記事ではこのフェーズ2にフォーカスします。 背景・課題 初期移行 初期移行は、ソースDB(オンプレミスSQL Server)からターゲットDB(Aurora MySQL)へのデータ一括移行です。全体の流れは以下の通りです。 抽出 : SQL Serverから bcp でCSV出力 転送 : CSVファイルをS3へアップロード ロード : LOAD DATA FROM S3 でAurora MySQLへインポート インデックス構築 : ALTER TABLE でインデックスを追加 制約と課題 今回の初期移行には、以下の制約がありました。 ソースDB(本番稼働中) : 影響を最小限に抑える必要がある ターゲットDB(サービスイン前) : 大胆な最適化が可能 この非対称な条件から、「 抽出は慎重に、インポートは大胆に 」という方針を採用しました。抽出には bcp (Bulk Copy Program)を採用しました。 bcp はSQL Server標準のバルクエクスポートツールであり、SELECT文による抽出と比較して以下の利点があります。 高スループット : 200,000〜500,000行/秒の安定した出力性能 シンプルな運用 : 追加のミドルウェアやライセンスが不要 転送ではS3を中継することで、ロード失敗時に再抽出せず再実行できる設計としています。 一方、事前試算では最大規模テーブルのインポートに 数日〜1週間 を要することが判明しました。ロード時間が長期化すると、以下のリスクが高まります。 接続切断・タイムアウト : 数日に及ぶ処理は中断リスクが高い 障害時の復旧困難 : 失敗時のデバッグと再実行に多大な時間を要する 移行スケジュールへの影響 : ダブルライト期間が長期化し、運用負荷が増大する ロールバック困難 : 問題発覚時に手戻りできる時間的余裕がなくなる これらのリスクを軽減するため、インポート処理の最適化が必須でした。 検証と最適化 本番移行に先立ち、約6,000万レコードを持つテーブルを用いて3つの観点で検証しました。 1. 並列化の効果 LOAD DATA FROM S3 MANIFEST でマニフェスト分割による並列実行を検証しました。CSVファイルを4分割・8分割・16分割と変化させましたが、スループットは 約51,000〜53,000行/秒で横ばい でした。 今回のAurora構成はProvisioned(単一ライターノード)であり、並列ロードを実行してもCPUおよびストレージI/O帯域がボトルネックとなります。Aurora Serverless v2のような動的スケーリング構成であれば結果が異なる可能性もありますが、今回の構成では並列化による改善は限定的でした。 2. インデックス戦略 方式 内容 処理効率 パターンA インデックスなしでLOAD → 後からALTERで追加 約61,000〜68,000行/秒 パターンB インデックスありでLOAD 約39,000〜42,000行/秒 パターンAが 最大59%高速 でした。行挿入ごとのインデックス更新はランダムI/Oを発生させますが、一括構築ならソート後、シーケンシャルに処理できます。ターゲットDBは未稼働のため、この最適化を採用しました。 3. インスタンスサイズ インスタンスタイプ別のスループットを比較しました。料金は Amazon Aurora の料金 を参照しています。 インスタンス インポート効率 ALTER効率 オンデマンド時間単価 r6i.2xlarge 約125,500行/秒 約120,300行/秒 約$0.63/時 r6i.16xlarge 約162,200行/秒 約162,800行/秒 約$5.00/時 r6i.16xlargeはr6i.2xlargeと比較して約30%のスループット向上が見られた一方、コストは約8倍です。このスループット差がテーブル規模によって処理時間に与える影響は以下の通りです。 大規模テーブル(数十億レコード) : 2〜3時間の短縮 → リスク低減に寄与 小規模テーブル(数千万レコード) : 数分の短縮 → コスト対効果が低い この結果から、大規模テーブルはr6i.16xlargeで時間短縮とリスク低減を図り、中小規模テーブルはr6i.2xlargeでコスト効率を最大化する ハイブリッド戦略 を採用しました。 本番移行の結果 検証結果をもとに本番移行を実施しました。最終的な移行実績は以下の通りです。 テーブル規模 テーブル数 LOAD DATA ALTER TABLE 総所要時間 最大規模(数十億レコード) 2 約4日 約7時間 約4日半 中規模(数億レコード) 1 約3時間 約20分 約3時間 小規模(数千万レコード) 5 約1時間 約10分 約1時間 合計 8 - - 約5日 数十時間に及ぶロードでは、以下のクエリで進捗を監視しました。 SET @target_rows = ?; -- 目標件数(テーブルの総行数) SET @thread_id = ?; -- 監視対象のスレッドID SELECT CONCAT ( ' Thread ' , trx.trx_mysql_thread_id) AS target_name, CONVERT_TZ(trx.trx_started, ' UTC ' , ' Asia/Tokyo ' ) AS 開始時刻_JST, ROUND (TIMESTAMPDIFF(SECOND, trx.trx_started, UTC_TIMESTAMP()) / 3600 , 2 ) AS 経過時間 _ 時間, trx.trx_rows_modified AS 挿入済み行数, @target_rows AS 目標件数, ROUND (trx.trx_rows_modified / TIMESTAMPDIFF(SECOND, trx.trx_started, UTC_TIMESTAMP()), 1 ) AS スループット _ 行毎秒, ROUND (trx.trx_rows_modified / @target_rows * 100 , 2 ) AS 進捗率 _ パーセント, ROUND ( (@target_rows - trx.trx_rows_modified) / (trx.trx_rows_modified / TIMESTAMPDIFF(SECOND, trx.trx_started, UTC_TIMESTAMP())) / 3600 , 2 ) AS 残り時間 _ 時間, DATE_ADD( CONVERT_TZ(NOW(), ' UTC ' , ' Asia/Tokyo ' ), INTERVAL ROUND ( (@target_rows - trx.trx_rows_modified) / (trx.trx_rows_modified / TIMESTAMPDIFF(SECOND, trx.trx_started, UTC_TIMESTAMP())) ) SECOND ) AS 完了見込み時刻_JST FROM information_schema.innodb_trx trx WHERE trx.trx_mysql_thread_id = @thread_id; information_schema.innodb_trx の trx_rows_modified から処理済み件数を取得し、経過時間で割ってスループットを算出します。目標件数との差分から残り時間と完了見込み時刻を推定し、数日に及ぶ処理においても見通しを立てられるようにしました。 得られた学び 学び 根拠 並列化は万能ではない マニフェスト分割を試みたが、単一ノードのI/O帯域がボトルネックとなり効果は限定的でした。闇雲に並列化するのではなく、律速段階を特定することが重要です インデックスは後付けが基本 ロード後に一括構築することで最大59%高速化。行挿入ごとのインデックス更新はランダムI/Oを発生させるが、一括構築ならソート後シーケンシャルに処理できる インスタンスサイズはテーブル規模で使い分ける 大規模テーブルはr6i.16xlargeで時間短縮とリスク低減、小規模テーブルはr6i.2xlargeでコスト効率を最大化。スループット向上率とコスト増加率のバランスを見極める 必ず本番同等データでリハーサルする 6,000万レコードでの検証結果を数十億レコードに線形外挿すると誤差が生じる。I/Oやメモリの振る舞いはデータ規模で変化するため、全量リハーサルが不可欠 やり直せる設計が安心を生む S3を中継することでロード失敗時も再抽出不要で再実行できる。数日かかる処理では「失敗しても復旧できる」という安心感が運用の質を高める この工程が安定したことで、後続の増分同期フェーズへ安全に進められました。 Embulkによる差分同期 初期移行が完了した後も、オンプレミスのSQL Serverには新規データが書き込まれ続けます。この増加分をAurora MySQLへ反映するため、 Embulk を用いた差分同期の仕組みを構築しました。 図中の「 マスタ 」はマイクロサービスがSQL Serverをマスタ(書き込みの主系)として参照・更新することを示しています。「 非同期 」はマイクロサービスがSQL Serverと同じ結果をAurora MySQLへ非同期に反映されることを示しています。「 保存 」はEmbulkジョブ完了後に差分の起点となる状態(config-diff)をS3へアップロードすることを指しています。「 復元 」は次回ジョブ起動時にS3からその状態をダウンロードすることを指しています。これにより前回の続きから差分取得を再開できます。 ジョブ設計 Embulkのインクリメンタル同期では、 updated_at のような更新日時カラムを差分キーとして利用するのがベストプラクティスです。しかし、今回の移行元テーブルはInsert/Deleteのみの操作で設計されており、レコードの更新(Update)が発生しないため updated_at に相当するカラムが存在しません。このテーブルの特性を踏まえ、操作種別ごとに差分キーを使い分ける設計を採用しました。 1つのテーブルに対して役割の異なる最大3つのジョブを用意しています。 ジョブ種別 インクリメンタル列 対象レコード 通常ジョブ 登録日( registered_at ) 新規追加されたレコード 削除ジョブ 削除日( deleted_at ) 論理削除されたレコード アーカイブジョブ 連番ID 削除テーブルへ移動済みのレコード 通常ジョブは登録日、削除ジョブは削除日をそれぞれ基準にレコードを取得します。 -- 通常ジョブ WHERE registered_at >= :registered_at -- 削除ジョブ WHERE deleted_at IS NOT NULL AND deleted_at >= :deleted_at アーカイブジョブでは、Embulkの before_load と after_load フックを活用し、以下の3ステップを1つのジョブ内で完結させています。 out : mode : merge_direct before_load : > UPDATE watermark SET id = (SELECT COALESCE(MAX(id), 0) FROM archived_favorites) after_load : > DELETE FROM favorites WHERE EXISTS ( SELECT 1 FROM archived_favorites WHERE archived_favorites.favorite_id = favorites.id AND archived_favorites.id >= (SELECT id FROM watermark) ) before_load でロード前のアーカイブテーブルの最大IDをウォーターマークとして記録し、 after_load でウォーターマーク以降の新規アーカイブ分に対応するお気に入りレコードを物理削除します。ウォーターマークがなければアーカイブテーブル全レコードが削除対象となり、毎回全件スキャンが発生します。ウォーターマークにより、今回のジョブで追加された差分だけに処理を限定しています。この設計により、お気に入り商品・ブランド・ショップの各テーブルに対してゲスト・会員の2種類を掛け合わせた複数パターンの差分同期を体系的に管理しています。 ソースDBへの負荷制御 差分同期では稼働中のオンプレミスSQL Serverからデータを読み取ります。本番サービスへの影響を抑えるため、複数のパラメータで負荷を制御しました。 # 共通入力設定(抜粋) in : type : sqlserver transaction_isolation_level : NOLOCK # ロック競合を回避 fetch_rows : 1000 # メモリ消費を抑制 SELECT TOP 10000 -- 1回あたりの取得行数を制限 registered_at, id, member_id, ... FROM favorites WITH (NOLOCK) WHERE registered_at >= :registered_at ORDER BY registered_at OPTION (MAX_GRANT_PERCENT = 25 ) -- クエリのメモリグラント上限を設定 NOLOCK ヒントでロック競合を回避し、 TOP N 句で1回あたりの取得行数を制限しています。 fetch_rows でJDBCのフェッチサイズを制御し、 MAX_GRANT_PERCENT でSQL Serverのクエリメモリグラント上限を設定しました。 また、embulk-input-sqlserverのインクリメンタルロードでは、対応する列型が整数型・文字列型・ datetime2 型に 限定されています 。しかし、移行元テーブルの日時カラムは smalldatetime 型であり、そのままではインクリメンタル列として使用できません。この制約の回避策として、クエリ内で CAST(削除日カラム AS DATETIME) と明示的に型変換しています。 データ整合性の担保 差分取得では > ではなく >= を使用しています。 > の場合、同一タイムスタンプに複数レコードが存在すると一部を取りこぼすリスクがあります。 >= では前回の最終レコードを重複取得する可能性があります。しかし、Embulkの出力モードを merge_direct に設定すれば、重複分はUPSERTとして吸収されます。 out : mode : merge_direct 「取りこぼし」と「重複」のトレードオフにおいて、 重複を許容しつつ冪等性で吸収する 方針を採用しました。 差分の起点となる状態管理にも工夫が必要でした。Embulkは --config-diff オプションにより、前回処理の最終レコード( last_record )をYAMLファイルに記録します。 in : last_record : [ '2023-12-23T09:00:30.000000' ] out : {} しかし、Kubernetes Jobとして実行する場合、Podはジョブ完了後に破棄されます。ローカルファイルシステム上の差分状態は失われるため、S3に永続化する仕組みを構築しました。 ジョブ開始時にS3から前回の差分状態をダウンロード Embulkによる差分同期の実行と差分状態の更新 ジョブ完了時に更新された差分状態をS3にアップロード ここで、ダウンロードとアップロードの失敗は致命的エラーとしてジョブを失敗させます。 設定管理とチューニング 複数パターンの設定ファイルは、対象テーブルやカラム名が異なるものの接続情報やパラメータは共通しています。EmbulkのLiquidテンプレート機能を活用し、共通部分を3つのテンプレートに集約しました。 共通テンプレート 役割 入力設定 SQL Server接続情報、トランザクション分離レベル、フェッチサイズ 出力設定 MySQL接続情報、出力モード SELECT句生成 環境変数に基づく TOP N 句の条件付き生成 個別の設定ファイルでは共通テンプレートをインクルードし、テーブル名・カラム名・WHERE句のみを定義します。SELECT句の共通テンプレートでは、環境変数が未設定の場合は TOP 句自体を生成せず、設定されている場合のみ行数制限を付与する条件分岐を実現しています。これにより、本番環境では制限なし、検証環境では制限ありという切り替えが可能です。 負荷制御パラメータ( TOP N 、 fetch_rows 、 MAX_GRANT_PERCENT 等)もすべて環境変数に切り出しており、コンテナイメージの再ビルドなしに変更を反映できます。テーブル単位で処理時間を計測してボトルネックを特定し、検証環境での調整結果を本番環境へ反映するサイクルを効率的に回せる設計です。 まとめ 本記事では、ZOZOTOWNのお気に入りデータベースにおける数十億レコード規模のデータ移行について、初期移行の最適化とEmbulkを用いた差分同期の取り組みを紹介しました。 初期移行では、インデックスの後付けやテーブル規模に応じたインスタンスサイズの使い分けにより、約5日間で全テーブルの移行を完了しました。差分同期では、 updated_at カラムが存在しない制約に対し、役割の異なる複数ジョブを設計することで、サービス無停止のまま増分データの反映を実現しました。 大規模データ移行やEmbulkによる異種DB間の差分同期を検討されている方にとって、本記事が参考になれば幸いです。今後はAurora MySQL単体運用への切り替えを進め、お気に入り機能のマイクロサービス化を完遂していきます。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
ZOZO開発組織の2026年2月分の活動を振り返り、ZOZO TECH BLOGで公開した記事や登壇・掲載情報などをまとめたMonthly Tech Reportをお届けします。 ZOZO TECH BLOG 2026年2月は、前月のMonthly Tech Reportを含む計16本の記事を公開しました。特に次の3記事は反響も大きく、とても多くの方に読まれています。いずれも「Claude Code」に関連した記事です。ぜひご一読ください。 techblog.zozo.com techblog.zozo.com techblog.zozo.com 登壇 CA DATA NIGHT#8 〜ZOZO×CA:マーケティングの意思決定を支えるデータサイエンス〜 2月5日に開催された「 CA DATA NIGHT#8 〜ZOZO×CA:マーケティングの意思決定を支えるデータサイエンス〜 」に、ビジネスアナリティクス部の茅原( @yusukekayahara )が登壇しました。 ZOZO.swift #2 2月10日にZOZOで主催した「 ZOZO.swift #2 」に、ZOZOTOWN開発1部の濵田( @ios_hamada )、ZOZOTOWN開発2部の森口( @laprasdrum )と續橋( @tsuzuki817 )、FAANS部の上田( @15531b )、ZOZOFIT開発部の渡邊が登壇しました。 techblog.zozo.com モバイルアプリの長期運用と向き合う ~10年以上続くアプリで重ねてきた判断と工夫~ 2月19日に開催された「 モバイルアプリの長期運用と向き合う ~10年以上続くアプリで重ねてきた判断と工夫~ 」に、ZOZOTOWN開発本部の髙井が登壇しました。 findy-code.io 掲載 Think IT Think ITに、昨年開催された「 GitHub Universe 2025 」に現地参加したFAANS部の輿水が座談会メンバーのひとりとして参加し、そのインタビュー記事が掲載されました。 GitHub Universe 2025、日本からの参加者による座談会を開催 | GitHub Universe 2025レポート | Think IT(シンクイット) ZOZO TECH BLOGに公開した、輿水の参加レポートもあわせてご覧ください。 techblog.zozo.com 以上、2026年2月のZOZOの活動報告でした! ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは。基幹システム本部・リプレイス推進部・リプレイス推進ブロックの岡本です。 私たちのチームでは、ZOZOの基幹システムリプレイスの一環として、会計領域のシステムを新規構築しています。アーキテクチャにはCQRS(Command Query Responsibility Segregation)+ES(Event Sourcing)を採用しました(以降、CQRS+ESと略記します)。 本記事では、CQRS+ESを実務へ適用する中で直面した「小さな集約を保ちながら、大量の集約をまたいだ業務出力をどう実現するか」という課題と、その解決で得られた知見を紹介します。 会計システムでは、決済に関連する明細データを決済ID単位の小さな集約(Aggregate)として設計しています。一方で、消込結果を月次でまとめた帳票を出力するようなユースケースでは数万件規模の集約を横断する必要があり、集約の境界と業務出力のスコープに不一致が生じます。この不一致により、Sagaによる協調の結果を1つのイベントでQuery側に届ける必要が生まれ、イベントペイロードの肥大化が問題となりました。私たちはこの問題を共有テーブルとシグナルイベントを組み合わせたパターンで解決しました。 なお、本記事で述べる会計システムの仕様は、実装上の問題構造を説明するために簡略化・抽象化したものであり、実際のシステム仕様とは異なります。CQRS+ESを実務に適用する中で同様の課題に直面している方々の一助となれば幸いです。 目次 はじめに 目次 背景 基幹システムリプレイスの概要 会計システムの概要 本記事のスコープと想定読者 なぜCQRS+ESを選んだか インフラ構成の選択 ── RDB 1つでCQRS+ESを実現する 集約の境界と業務出力のスコープの不一致 小さな集約と大きな出力 スコープの不一致が生む課題 Sagaで複数集約を協調させる Sagaによる協調の構成 協調の次に来る問題:Query側へのデータ伝達 Query側へのデータ伝達 ── イベントに載せきれないとき ベストプラクティス:イベントに全情報を載せてQuery側に渡す 数万件規模のデータをイベントに載せるべきか? 採用したパターン:共有テーブル+シグナルイベント このパターンの解釈 CQRS+ESを実践してみて まとめ 背景 基幹システムリプレイスの概要 ZOZOの基幹システムは、20年以上にわたり機能追加を重ねてきた大規模モノリスです。技術的負債の蓄積により保守・拡張コストが増大していたことから、現在、全社的な基幹システムリプレイスプロジェクトが進行しています。 このリプレイスでは、重要度と移行コストの両面を考慮した上で優先度をつけ、モノリスからの段階的な移行を進めています。リプレイスプロジェクトの背景や先行事例については「 モノリスからマイクロサービスへ─ZOZOBASEを支える発送システムリプレイスの取り組み 」で詳しく紹介しています。 最新の基幹システムリプレイスの状況については「 巨大モノリスのリプレイス──機能整理とハイブリッドアーキテクチャで挑んだ再構築戦略 」の発表資料にまとめています。発表の様子は「 アーキテクチャConference 2025 協賛&参加レポート 」で紹介しています。 会計システムの概要 私たちが取り組んでいる会計システムリプレイスは、発送システムと同様に基幹システムから独立したマイクロサービスとして新規に構築しています。 会計システムが扱うドメインの中核は、「弊社システムの売上実績のデータ」と「決済代行会社などの外部システムの入金実績のデータ」を突合する処理です。 会計用語でいう「入金の消込」にあたります。売上と入金の明細は各々任意のタイミングで到着します。その都度、決済ID単位で明細を照合し消込処理を実行する必要があります。 本記事のスコープと想定読者 このシステムのアーキテクチャとして、CQRS+ESを採用しました。本記事ではCQRS+ESの採用理由にも軽く触れますが、本題は Aggregateの整合性境界と業務出力のスコープが一致しない場合 に生じる設計課題と、その解法です。具体的には、数万件規模のデータをどのようにQuery側に届けるかという問題を扱います。 想定読者はCQRS+ESの基本的な概念を理解している方です。何らかのCQRS+ESフレームワークに触れたことがある方は、より興味深く読んでいただけます。 なぜCQRS+ESを選んだか 会計システムでは、すべての業務操作の履歴を厳密に記録し、後から追跡可能にすることが求められます。 Event Sourcing では、ビジネスエンティティの状態を「状態変更イベントの列」として永続化します。そのため、業務イベントの履歴がそのまま監査ログとして機能するという性質が、会計ドメインの要件と合致しました。 ここで重要なのは、ログとイベントの違いです。ログを記録するだけでは、ログと実際のシステムの動作が整合している保証はありません。一方、ESではイベント(事実)がすべての起点であり、イベントと動作が必ず整合します。会計システムにおいて「何が起きたか」を正確に追跡できることは、監査の観点から本質的な要件です。そのため、ESの採用が適切であると判断しました。 また、Queryの都合を気にしてドメインモデルを構築すると、最も重要なCommand側のロジック管理が複雑化します。CQRSによりCommandとQueryのモデルを分離することで、それぞれの関心事に集中した設計が可能になります。 社内の技術スタックをJavaに統一しており、Java上でCQRS+ESを実現するフレームワークとして Axon Framework を採用しました。Axon Frameworkを選定した理由の1つは、CQRS+ESの実践に必要なプラクティスがフレームワークレベルで用意されている点です。具体的には、以下のような仕組みがフレームワークとして提供されています。 イベントの永続化とリプレイ スナップショットによる集約の復元最適化 Sagaによる複数集約の協調 Processing Groupとセグメントによる並列処理の制御 これらを自前で実装する必要がないことで、CQRS+ESの基盤構築ではなく、ドメインの設計に集中できると判断しました。 インフラ構成の選択 ── RDB 1つでCQRS+ESを実現する 一般的なCQRSアーキテクチャでは、Command側とQuery側を別々のデータストアに分離し、メッセージブローカーを介してイベントを伝達する構成が採用されます。下図は、 Axon公式ドキュメント に示されている一般的なCQRSアプリケーションの技術概要を参考に再作成したものです 1 。 公式図では、Event Store・Event Bus・Query側のデータベースがそれぞれ独立したコンポーネントとして描かれています。これらのインフラ構成には複数の選択肢があります。たとえばイベントストアとメッセージルーティングを一体で提供するAxon Serverや、Event BusにKafkaなどのメッセージブローカーを採用する構成が考えられます。 私たちのシステムではESの主な採用動機が監査ログの実現であり、高いスケーラビリティや外部システムへのイベント連携は要件ではありませんでした。そのため、これらの選択肢を以下の2つの観点から評価した結果、いずれも採用を見送りました。 金銭的コスト :Axon Serverのクラスタ構成のライセンス費用や、メッセージブローカーの追加インフラコストが発生する 学習コスト :チームにとってなじみの薄い技術スタックを導入した場合、学習コストと運用負荷が高くなる チームに知見のあるRDBのみの構成でも要件を満たせることがわかり、 Event Store・Event Bus・Read Modelをすべて単一のRDB上で実現する構成 を採用しました。下図は、今回採用した単一RDB構成を示しています。 今回の構成では、独立したEvent Busコンポーネントは存在しません。Axon FrameworkがEvent Store( domain_event_entry テーブル)をポーリングすることで、Event Busの役割を実現しています。また、RDB上でのパフォーマンスを確保するために、Axon公式の RDBMSチューニングガイド を参考にインデックス設定等のチューニングを行っています。 私たちの構成では、同一データベース内にCommand側テーブル、Query側テーブル、そして共有テーブルが同居しています。Command側のテーブル( domain_event_entry や token_entry 等)はAxon Frameworkが内部的に利用するテーブルであり、フレームワークが必要とするスキーマをそのまま作成しています。Query側のテーブルはRead Modelを表す rm_ プレフィックスで管理しています。共有テーブルは標準構成ではなく私たちが独自に導入したものであるため、図中では点線で表記しています。詳細は次章以降で説明しますが、この「すべてが同一データベース内に存在する」という構成が、共有テーブルパターンの前提条件として重要な役割を果たします。 集約の境界と業務出力のスコープの不一致 小さな集約と大きな出力 私たちのシステムでは、 Aggregate(集約) を小さな単位で保つ設計を採用しています。Vaughn Vernon氏は「Effective Aggregate Design」の中で、集約の設計について以下のように述べています。 Limit the Aggregate to just the Root Entity and a minimal number of attributes and/or Value-typed properties. (...) A large-cluster Aggregate will never perform or scale well. (日本語訳)集約はルートエンティティと最小限の属性やValue型プロパティに限定すべきである。(中略)大きなクラスタの集約は、パフォーマンスもスケーラビリティも決して良くならない。 ── Vaughn Vernon, " Effective Aggregate Design Part I " この指針に従い、私たちのシステムでも集約を小さな単位で保っています。「背景」で述べた通り、売上と入金の明細を決済ID単位で照合するため、各集約も同じ粒度で設計しており、毎日膨大な数の集約インスタンスが生まれます。 決済ID単位の小さな集約にする必然性は、各明細が自身の状態に基づいて独立した判断・振る舞いを行う必要があるためです。各集約は消込に関するステータスを内部に保持しています。さらに、各明細に対しては削除コマンドを受け付ける要件があります。削除コマンドを受けた際、その明細がすでに帳票出力済みであれば打ち消しの帳票を出力してから削除するといった、明細単位の状態(消込ステータス、帳票出力済/未済等)に応じた振る舞いの分岐が求められます。このように、個々の明細が自身の状態に基づいて独立して判断する必要があるため、小さな集約としての設計が必然です。 一方で、帳票出力という業務処理は、これら数万件規模の集約を横断する大きなスコープで実行されます。 帳票出力時には数万件規模の集約のステータスを「出力済」に更新し、さらにQuery側(Read Model)では、ステータスが更新された数万件規模のデータをもれなく帳票として出力する必要があります。 スコープの不一致が生む課題 下図は、この「スコープの不一致」を示しています。各集約は決済ID単位の小さな境界を持っていますが、帳票出力のスコープは数万件規模の集約を横断します。 1つの集約のスコープと業務出力のスコープには大きなギャップが存在します。この構造は、小さな集約という設計が正しいからこそ生まれる問題です。集約を大きくすれば解消できますが、それはVernon氏が指摘する「大きな集約のアンチパターン」に陥ることを意味します。したがって、集約の境界はそのまま維持した上で、数万件規模の集約を横断的に協調させる仕組みが必要になります。 Sagaで複数集約を協調させる Sagaによる協調の構成 前章で示した「数万件規模の集約を横断的に協調させる」という課題に対して、 Saga を採用しました。Sagaは、複数のローカルトランザクションを協調させるパターンです 2 。 私たちの構成では、Sagaが数万件規模の集約にCommandを送信し、各集約が処理完了後にEventを返却し、Sagaがそれらを収集して全体の完了を判断します。実際にはSagaを親子に階層化し、親Sagaが子Sagaを複数起動して、子Sagaがバッチ単位で集約を管理する構成を採用しています。これにより、並列処理の流量制御も実現しています。下図は、この協調フローの概念を示しています。 子Sagaは各集約からの完了イベントを受け取るたびに処理済みの件数をカウントし、すべての集約の処理が完了した時点で親Sagaに完了を通知します。なお、集約が別のユースケースで削除済み、またはすでに帳票出力済みであった場合は、帳票出力の対象外であることを示すイベントを返却します。Sagaはこのイベントも処理済みとしてカウントし、帳票には出力しないものとして扱います。親Sagaはすべての子Sagaの完了をもって「全体完了」と判断します。数万件規模の集約を横断的に協調させるという課題自体は、このSagaの階層構造で解決できます。 協調の次に来る問題:Query側へのデータ伝達 Sagaが「全集約の処理が完了した」と判断した次のステップで、新たな問題が生まれます。数万件規模の処理結果を、Query側にどのように届ければよいのでしょうか。 Query側へのデータ伝達 ── イベントに載せきれないとき ベストプラクティス:イベントに全情報を載せてQuery側に渡す CQRS+ESにおけるベストプラクティスは、 イベントに必要な情報をすべて載せてQuery側に渡す ことです。 Microsoftの CQRS Patternガイド では、Command側とQuery側の同期について次のように述べています。 When you use separate data stores, you must ensure that both remain synchronized. A common pattern is to have the write model publish events when it updates the database, which the read model uses to refresh its data. (日本語訳)別々のデータストアを使用する場合、両方の同期を保つ必要があります。一般的なパターンは、書き込みモデルがデータベースを更新する際にイベントを発行し、読み取りモデルがそのイベントを使用してデータを更新するというものです。 ── Microsoft Azure Architecture Center, "CQRS Pattern" イベントがすべての情報を運ぶことにより、Query側はCommand側のデータストアを直接参照する必要がなくなります。この「イベントを通じた疎結合」こそがCQRSの根幹です。Query側のProjection(イベントからRead Modelを導出する処理)は、受信したイベントのペイロードだけでRead Modelを構築できます。そのため、Command側とQuery側の独立性が保たれます。 数万件規模のデータをイベントに載せるべきか? 私たちのケースでこのベストプラクティスをそのまま適用できるでしょうか。前章で示した通り、Sagaが全集約の完了を検知した時点で数万件規模の処理結果をQuery側に届ける必要があります。ベストプラクティスに従えば、これらすべてのデータを完了イベントのペイロードに含めるべきです。 しかし、ここには2つの問題があります。1つ目は ペイロードの肥大化 です。数万件規模の集約に関するデータを1つのイベントに詰め込むことは、シリアライズ・デシリアライズのコストやメモリ使用量の観点から非効率です。2つ目は Query側での利用形態との不一致 です。帳票出力の後続処理では、前段のProjectionで構築済みの rm_ テーブルとのJOINが必要です。仮にイベントペイロードにデータを収められたとしても、Query側で結局テーブルに展開してJOINすることになるため、イベント経由で運ぶ利点は薄れます。 採用したパターン:共有テーブル+シグナルイベント 先述の問題に対して、いくつかの方針を検討しました。 1つ目は Query側のProjectionで完結させるアプローチ です。各集約の処理完了イベントをProjectionが受信して rm_ テーブルに書き込み、すべての書き込みが終わった後に帳票を出力する方式です。 しかし、数万件規模のイベントを実用的な時間内に処理するにはProjectionの並列化が必須です。Axon FrameworkのTracking Processorでは、複数のセグメントがイベントを分担して並列に処理します。同一セグメント内ではイベントの処理順序が保証されますが、完了イベント(シグナルイベント)と各集約の処理完了イベントは異なるセグメントに振り分けられうることが問題です。 異なるセグメント間では処理の進行度が異なるため、あるセグメントが完了イベントを処理した時点で、別セグメントではまだ処理が完了していない可能性があります。 つまり、シグナルイベントがProjectionに届いた時点で rm_ テーブルへの書き込みが完了していない可能性があり、データの欠損が生じます。これを防ぐにはProjectionに協調ロジックが必要ですが、それはSagaの責務であり、Projectionの関心事の分離を崩すため、見送りました。 2つ目は イベントの分割送信 (チャンク化)です。数万件のデータをN件ずつ複数のイベントに分割して送信する方式です。しかし、この方式ではQuery側のProjectionが「すべてのチャンクが届いたか」を判定する協調ロジックを持つ必要があり、1つ目と同じ問題構造を抱えるため、見送りました。 3つ目は Claim Checkパターン です。イベントにはデータ本体を載せず、外部ストレージへの参照のみを含める方式です。技術的には実現可能ですが、以下の理由から見送りました。 外部ストレージの導入は「インフラ構成の選択」で述べた単一RDB構成の方針を崩す 外部ストレージへの書き込みはEvent Storeと別トランザクションになり、障害時の整合性担保が複雑化する これらの検討を経て、私たちは単一RDB構成の利点を活かした 共有テーブルとシグナルイベントを組み合わせたパターン を採用しました。前述の通り、個々の明細データは通常のProjectionでRead Modelに構築済みです。不足しているのは、どの明細がどの帳票に属するかという対応関係です。このパターンの構成は以下の通りです。 Sagaは帳票出力フローの開始時に帳票IDを採番し、各集約にCommandを送信する。処理完了イベントを受信するたびに、 同一トランザクション内で 帳票IDと明細IDの対応関係を 共有テーブル に逐次書き込む すべての集約の処理が完了したら、Sagaは 完了イベント を発行する(ペイロードは最小限のシグナルのみ) Query側のProjectionは完了イベントをトリガーとして受信し、帳票出力が可能になったことを示すRead Model( rm_ テーブル)を作成する 後続のレポート生成処理がこのRead Modelを検知し、帳票のRead Model・共有テーブル・明細のRead Modelを順にJOINして帳票データを取得する ステップ1のポイントは、Axon FrameworkのSagaがイベントハンドラの処理をUnit of Work(UoW)パターンで管理している点です。イベントの受信と共有テーブルへの書き込みが同じトランザクションで実行されるため、すべての集約の処理が完了した時点では、対応するデータが共有テーブル上にも確実にそろっています。 ここで重要なのは、「インフラ構成の選択」で説明した 単一RDB構成 です。Command側テーブル、Query側テーブル、そして共有テーブルがすべて同一のデータベース内に存在するため、共有テーブルへの書き込みとJOINによる読み取りが自然に実現できます。もしCommand側とQuery側が異なるデータストアに分離されていたら、このパターンは成立しません。 先述のProjection完結アプローチで問題となったセグメント間の進行度の差は、本パターンでは構造的に発生しません。共有テーブルへの書き込みをSagaが担い、すべての書き込みが完了した後に初めて完了イベントを発行するためです。 このパターンの解釈 このパターンでは文字通りCommand側とQuery側でテーブルを共有しています。これはCQRSの原則「Command側とQuery側はイベントを通じてのみ情報をやり取りする」からの意図的な逸脱です。将来的なデータストアの物理分離が難しくなるトレードオフはありますが、以下の2点を考慮し採用しました。 現時点でCommand側とQuery側の物理分離は想定されないこと 共有テーブルは明示的に設計・管理されており、暗黙の依存ではないこと。将来的に物理分離が必要になった場合も、共有テーブルの参照箇所が明確であるため、段階的な移行が可能であること 実際にこの設計で運用してみて、Projectionのロジックがシンプルに保たれ、Event Storeのペイロード肥大化も回避できている点に手応えを感じています。一方で、共有テーブルのスキーマ変更がCommand側とQuery側の両方に影響する点には注意が必要です。通常のCQRSでは、Command側とQuery側のスキーマを独立に変更できることが利点の1つですが、共有テーブルに関してはこの利点が失われます。 CQRS+ESを実践してみて 本記事で紹介したSagaによる数万件規模の集約の協調は、Axon FrameworkのSagaサポートがなければ実現が困難でした。その場合、Sagaの状態管理やイベントとの紐付けといった基盤部分の実装から始める必要がありました。同様に、スナップショットによる集約の復元最適化やProjectionの進捗管理(Tracking Processor)も、自前で実装していたら多大な工数を費やしていたと考えられます。前述したこれらの基盤が揃っていたからこそ、アーキテクチャレベルの設計課題に対して検討と試行錯誤の時間を確保できました。 加えて、Axon FrameworkでESを実現する中で、集約内部のロジックが関数的な構造になる点にも良さを感じています。集約のCommand Handlerは、Commandを受け取ってEventを発行し、Event Sourcing Handlerは、Eventを受け取って集約の状態を更新します。テストも、Axon Frameworkが提供する テストフィクスチャ を用いて「Given(過去のイベント列)→ When(コマンド)→ Then(期待されるイベント)」という宣言的な形式で記述できます。この構造は、AIによるテスト駆動開発と相性が良いと感じています。入力と出力が明確に定義されているため、AIがテストケースを生成しやすく、またテストの意図が宣言的に表現されるため、AIが生成したテストコードのレビューもしやすいという実感があります。 一方で、ESを本格的に運用する難しさも実感しています。 ESではすべての状態変更が「コマンド → 集約 → イベント」のパイプラインを通ります。ステートソーシングであれば一括更新で済む処理も、集約ごとにコマンドを送信し、個別にイベントを発行しなければなりません。 本記事で扱った集約横断の協調は、まさにこの制約から生まれた設計課題です。 この課題に関連して、近年提唱されている Dynamic Consistency Boundary(DCB) という概念に注目しています。DCBは、一貫性の境界を集約に固定せず、イベントへ付与するタグに基づいて動的に伸縮させるアプローチです。従来のESでは集約の境界が設計時に固定されるため、本記事で扱ったようなSagaによる協調が避けられませんでしたが、DCBによってこの複雑さを軽減できる可能性があります。私たちのユースケースにどこまで適用できるかはまだ未知数ですが、ESの実践的な課題を構造的に解決しうるアプローチとして、今後の動向を追っています。 まとめ 本記事では、会計システムへのCQRS+ES適用において、小さな集約を保ちながら大量の集約をまたいだ業務出力を実現する過程で得られた知見を紹介しました。 小さな集約を正しく設計するほど、業務出力のスコープとの不一致が顕在化します。Sagaで数万件規模の集約を協調させることはできますが、その結果をQuery側に届ける段階で「イベントに載せきれない」という壁にぶつかりました。共有テーブルとシグナルイベントを組み合わせたパターンを採用し、CQRSの原則からは逸脱しつつも、実用的な解決策にたどり着きました。 CQRS+ESの実装事例はまだ多くなく、今回の実装についても正しいものであるかという不安と向き合いながら進めてきました。リリースしてみて大きな問題は発生しておらず、ポジティブな状況であると捉えています。しかし、ベストプラクティスがさらに確立されてきた際には、それに適応していく姿勢を持ち続けたいと考えています。 本記事では会計領域のリプレイスを紹介しましたが、同じ基幹システムリプレイスの物流領域でもメンバーを募集しています。大規模モノリスからのサービス分割に取り組むポジションで、ドメイン駆動設計やイベント駆動アーキテクチャの知識を活かせる環境です。物流システムの刷新に興味のある方は、ぜひご覧ください。 hrmos.co さらにZOZOでは、一緒にサービスを作り上げてくれる仲間を広く募集中です。ご興味のある方は、以下のリンクからぜひご覧ください。 corp.zozo.com この図はAxon公式ドキュメント「Architecture Overview」の図を参考に、本記事で必要な構成要素に絞って再作成したものです。 ↩ 厳密には、SagaとProcess Managerは 異なる概念 です。Sagaは補償トランザクションに焦点を当てたパターンであるのに対し、Process Managerは状態マシンとしてモデリングされ、受信イベントと現在の状態に基づいて判断を下します。Axon Frameworkでは @Saga アノテーションを使用して、Orchestration方式のProcess Managerを実装しています。本記事では、フレームワークの慣例に合わせて「Saga」と表記します。 ↩
はじめに こんにちは。グローバルプロダクト開発本部 グローバルアプリ部 アプリ基盤ブロックの桂川です。普段はZOZOFIT・ZOZOMETRYなどの計測アプリのAndroid開発に携わっています。本記事ではZOZOFITのAndroidアプリで取り組んだMVVMからMVIへの移行と、独自MVIライブラリの開発について紹介します。なお、独自MVIライブラリを使ったMVIアーキテクチャへの移行は2024年9月に開始しました。 目次 はじめに 目次 用語 ZOZOFIT MVVM SSOT UDF MVI 私たちのMVVMアーキテクチャの問題点 ViewModelでのState管理が複雑に ViewとViewModelの責務が曖昧に イベント通知と画面遷移の不統一 私たちのMVVMアーキテクチャの改善方針 UiStateによるState管理の単純化 ユーザー操作ごとのメソッド定義による責務の明確化 Channelによるイベント通知と画面遷移の統一 私たちのMVVMアーキテクチャの改善方針を運用できるか MVIアーキテクチャの導入と独自ライブラリの作成 データフロー 実装 インタフェースの定義 移譲を用いたインタフェースの実装 MVIアーキテクチャを独自MVIライブラリで実装する Contract: State・Action・SideEffectの定義 ViewModel: Actionの処理とState更新 View: MviContentによるCompose連携 テスト: Actionを送信してState・SideEffectを検証 MVVMアーキテクチャからMVIアーキテクチャに移行してみて チーム全体で一貫した実装ができるようになった PRレビューの質が向上した AIコーディングエージェントとの協業がしやすくなった まとめ 用語 まず、本記事で使用する用語を整理します。 ZOZOFIT ZOZOFITは、自宅で手軽に高精度な3Dボディスキャンができる体型管理サービスです。ZOZOSUITと専用スマートフォンアプリを活用し、全身3Dスキャンが可能です。計測データに基づき、体の変化を3Dモデルと数値で可視化できます。栄養素を記録・分析するフードジャーナル機能など、計測以外の機能でも総合的な健康管理をサポートしています。本記事ではアメリカなど海外で展開しているZOZOFITのAndroidアプリでの改善についてお話しします。 zozofit.com MVVM MVVM(Model-View-ViewModel)は、UIの状態を管理するアーキテクチャスタイルの1つです。Model・View・ViewModelの3要素で構成され、ViewModelがModelとViewの仲介役を担います。ViewはViewModelが公開する状態を監視して画面に反映し、ユーザー操作はViewModelのメソッドを呼び出すことで処理されます。Androidアプリ開発で広く採用されているアーキテクチャです。データの流れは次のとおりです。 Viewがユーザー操作をViewModelのメソッド呼び出しとして送る ViewModelが状態を更新し、StateFlowで公開する ViewがStateFlowを購読して画面に反映する SSOT SSOT(Single Source of Truth)は、各データ型に対して唯一の信頼できるデータソースを持つ考え方です。SSOTだけがデータを変更でき、不変の型で公開します。これによりデータの変更が1箇所に集約され、他の型による改ざんを防ぎ、バグの追跡を容易にします。 UDF UDF(Unidirectional Data Flow)は、SSOTと組み合わせて使用されるパターンです。状態(データ)は上位から下位へ一方向に流れ、状態を変更するイベントはその逆方向に流れます。具体的には次の流れでデータが更新されます。 Android公式ドキュメント でも、堅牢なアーキテクチャの原則としてSSOTとUDFが示されています。この2つをセットで守ることで、データの整合性が保たれ、デバッグ・テスト・レビューがしやすくなります。本記事で紹介するMVIアーキテクチャもこの原則に基づいており、SSOTとUDFの理解が必要です。 ユーザー操作(ボタン押下など)が下位スコープで発生する イベントが下位スコープから上位スコープ(SSOT)へ向かって流れる SSOTでデータが変更され、不変の型として公開される 変更された状態が上位スコープから下位スコープへ流れる 下位スコープが新しい状態を受け取り、表示を更新する MVI MVI(Model-View-Intent)は、UDFの原則に基づいてUIの状態を管理するアーキテクチャスタイルの1つです。データの流れが一方向に固定されるため、状態変更の起点と結果が追跡しやすくなります。MVIの名前はModel・View・Intentの頭文字に由来しており、以下の3要素で構成されます。なお、本記事では用語の紛らわしさを避けるため、以降ModelをState、IntentをActionと呼びます。 要素 役割 Model(State) 画面の現在状態を表すデータ。UIはこの値のみから構築される。 View Stateを受け取って画面に反映し、ユーザー操作をActionとして発行する。 Intent(Action) ユーザー操作や外部イベントなど、状態更新のきっかけとなる入力。 Viewがユーザーの操作をActionとして発行する ActionをもとにStateが更新される 更新されたStateがViewへ通知され、画面に反映される 私たちのMVVMアーキテクチャの問題点 ZOZOFITのAndroidアプリは2022年のリリース当初からJetpack Composeを採用しており、当時からMVVMアーキテクチャを採用して開発を続けていました。私たちのMVVMアーキテクチャではViewModelで定義したStateFlowをViewで購読し、ViewModelのメソッドをViewから呼び出して状態を更新する、というシンプルな設計でした。 class CounterViewModel : ViewModel() { private val _counter = MutableStateFlow( 0 ) val counter: StateFlow< Int > = _counter.asStateFlow() fun increment() { _counter.value + = 1 } fun decrement() { _counter.value - = 1 } fun reset() { _counter.value = 0 } } しかし開発が進み画面数や機能が増えるにつれて、Jetpack ComposeとMVVMの組み合わせにおいて、いくつかの問題が顕在化していきました。特にStateFlowの管理やイベント通知の設計がチーム内で統一されておらず、不具合やレビュー負荷の増加につながっていました。具体的には以下のような課題がありました。 ViewModelでのState管理が複雑に 表示データごとに個別のStateFlowを定義していたため、画面が複雑になるほど Flow.map や combine による合成が増えていきました。各Flowの更新タイミングが把握しづらくなり、意図しない再Composeや画面のチラつきが発生していました。 // CounterViewModel.kt: 表示データごとに個別のFlowが定義されている class CounterViewModel : ViewModel() { private val _counter = MutableStateFlow( 0 ) val counter: StateFlow< Int > = _counter.asStateFlow() // Flow.mapで派生StateFlowを作成 → 更新タイミングが分かりにくい val doubleCount: StateFlow< Int > = _counter.map { it * 2 } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 0 ) val tripleCount: StateFlow< Int > = _counter.map { it * 3 } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 0 ) } またView側のComposable関数でも引数が増えていく傾向がありました。View側のコードに多くの collectAsState が定義され、見通しが悪く、管理が難しいコードになることも多々ありました。 // CounterScreen.kt: Flowごとに個別にcollectし、引数が増えていく @Composable fun CounterScreen(viewModel: CounterViewModel, navController: NavController) { val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() val counter by viewModel.counter.collectAsStateWithLifecycle() val doubleCount by viewModel.doubleCount.collectAsStateWithLifecycle() val tripleCount by viewModel.tripleCount.collectAsStateWithLifecycle() CounterScreenContent( isLoading = isLoading, counter = counter, doubleCount = doubleCount, tripleCount = tripleCount, onIncrement = { /* ... */ }, onDecrement = { /* ... */ }, onReset = viewModel :: reset, // ... ) } ViewとViewModelの責務が曖昧に ViewがViewModelの構造を知りすぎるコードになりがちで、本来ViewModelで完結すべきロジックがView側に漏れ出していました。ViewModelのプロパティを直接読み取って条件分岐する実装や、複数メソッドを特定の組み合わせで呼び出す実装が各所に存在していました。 // ViewがViewModelのプロパティを直接読み取ってToast表示を制御している val context = LocalContext.current Button( onClick = { viewModel.increment() if (viewModel.currentCount == 10 ) { Toast.makeText(context, "10に到達しました" , Toast.LENGTH_SHORT).show() } } ) { Text( "Increment" ) } // 1つのユーザー操作に対してView側が複数メソッドを組み合わせて呼んでいる Button( onClick = { viewModel.increment() viewModel.checkLimit() } ) { Text( "Increment" ) } このようにViewがViewModelの構造を知りすぎているため、機能変更時の影響範囲が広がりやすくなり、レビュー負荷や不具合の原因になっていました。 イベント通知と画面遷移の不統一 Toast表示や画面遷移といった一度きりの処理について、実装パターンが明確に統一されていませんでした。Toast表示ではViewModelからイベントを発行してView側で購読するパターンと、View側でStateを直接監視して処理するパターンが混在していました。 // CounterScreen.kt: ViewModelのイベント経由でToast表示 LaunchedEffect( Unit ) { viewModel.event.collect { event -> when (event) { is CounterEvent.ShowToast -> Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() } } } Button(onClick = { viewModel.increment() }) { Text( "Increment" ) } // CounterScreen.kt: View側でStateを直接監視してToast表示 val counter by viewModel.counter.collectAsStateWithLifecycle() LaunchedEffect(counter) { if (counter >= 10 ) { Toast.makeText(context, "10に到達しました" , Toast.LENGTH_SHORT).show() } } Button(onClick = { viewModel.increment() }) { Text( "Increment" ) } 画面遷移についてもViewModelのイベント経由で遷移するパターンと、Composable関数から直接Navigatorを呼び出すパターンが混在していました。 // CounterScreen.kt: ViewModelのイベント経由で画面遷移 LaunchedEffect( Unit ) { viewModel.event.collect { event -> when (event) { is CounterEvent.NavigateSetting -> navController.navigateSetting() } } } Button(onClick = { viewModel.navigateSetting() }) { Text( "Setting" ) } // CounterScreen.kt: Composable関数から直接Navigatorを呼び出して画面遷移 Button(onClick = { navController.navigateSetting() }) { Text( "Setting" ) } 方式が統一されていないため、新しい画面を実装する際にどの方式へ合わせるべきか判断しづらく、開発者ごとの実装のばらつきを招いていました。さらにStateを直接監視する方式では、画面に戻ってきた際にイベントが再発火して意図しない動作が発生する不具合も起きていました。 私たちのMVVMアーキテクチャの改善方針 これらの問題を放置すれば開発効率・品質ともに低下し続けるため、各課題に対して以下のような解決方針を考え、まずは既存のMVVMアーキテクチャの枠組みの中で改善できないか検討を進めました。 課題 解決方針 State管理の複雑化 画面の状態を1つのdata classに集約し、単一のStateFlowで管理する ViewとViewModelの責務が曖昧 ユーザー操作をイベントとして定義し、処理をViewModel内に集約する イベント通知と画面遷移の不統一 イベント通知をChannelに統一し、画面遷移もイベント経由に統一する UiStateによるState管理の単純化 SSOTの原則に従い、画面の状態を1つのdata classに集約して単一のStateFlowで管理する方針を考えました。Viewは信頼できる唯一のソースを購読して画面に反映するだけのシンプルな構造になります。また状態の更新が _state.update に集約されるため、 Flow.map や combine による合成が不要になり、更新タイミングも制御しやすくなると考えました。 // CounterUiState.kt: 画面の状態を1つのdata classに集約し、派生値もdata class内で計算する data class CounterUiState( val count: Int = 0 , ) { val doubleCount: Int get () = count * 2 val tripleCount: Int get () = count * 3 } // CounterViewModel.kt: 単一のStateFlowで管理し、ユーザー操作ごとにメソッドを定義 class CounterViewModel : ViewModel() { private val _state = MutableStateFlow(CounterUiState()) val state: StateFlow<CounterUiState> = _state.asStateFlow() fun onIncrementClicked() { _state.update { it.copy(count = it.count + 1 ) } } } // CounterScreen.kt: View側は単一のStateを購読するだけ @Composable fun CounterScreen(viewModel: CounterViewModel, /* ... */ ) { val state by viewModel.state.collectAsStateWithLifecycle() CounterScreenContent( state = state, onIncrement = viewModel :: onIncrementClicked, // ... ) } ユーザー操作ごとのメソッド定義による責務の明確化 UDFの原則に従い、ViewからのAction(ユーザー操作)に反応してStateが更新されるシンプルな構造を考えました。ユーザー操作ごとにメソッドを定義し、関連する更新処理をすべてそのメソッド内に集約します。これによりView側はユーザー操作をViewModelに伝えるだけの役割になり、具体的な処理はすべてViewModel側で完結するため、責務が明確になると考えました。 // CounterViewModel.kt: ユーザー操作(Action)ごとにメソッドを定義し、処理をViewModel内に集約 class CounterViewModel : ViewModel() { private val _state = MutableStateFlow(CounterUiState()) val state: StateFlow<CounterUiState> = _state.asStateFlow() fun onIncrementClicked() { viewModelScope.launch { _state.update { it.copy(count = it.count + 1 ) } checkLimit() } } private suspend fun checkLimit() { /* ... */ } } // CounterScreen.kt: ViewはActionを発行するだけ CounterScreenContent( state = state, onIncrement = viewModel :: onIncrementClicked, onDecrement = viewModel :: onDecrementClicked, onReset = viewModel :: onResetClicked, ) Channelによるイベント通知と画面遷移の統一 イベント通知と画面遷移の方式をChannelに統一する方針を考えました。一度限りのイベントをsealed classで定義し、Channelで配信することで、StateFlowのように状態として保持されず再受信による不具合を防げます。 画面遷移もイベントの一種として扱い、すべてViewModel経由で発行する形に統一します。単純な遷移であればViewから直接呼び出す方がシンプルですが、実際には遷移前の条件チェックやパラメータの組み立てが必要になるケースが多いです。そのためViewModel側に集約する方が一貫性を保ちやすいと判断しました。 // CounterEvent.kt: イベントと画面遷移をsealed classで定義 sealed class CounterEvent { data class ShowToast( val message: String ) : CounterEvent() data object NavigateSetting : CounterEvent() } // CounterViewModel.kt: イベント通知と画面遷移をChannelで統一的に配信 class CounterViewModel : ViewModel() { private val _state = MutableStateFlow(CounterUiState()) val state: StateFlow<CounterUiState> = _state.asStateFlow() private val _event = Channel<CounterEvent>(Channel.BUFFERED) val event: Flow<CounterEvent> = _event.receiveAsFlow() fun onIncrementClicked() { viewModelScope.launch { _state.update { it.copy(count = it.count + 1 ) } checkLimit() } } fun onSettingClicked() { viewModelScope.launch { _event.send(CounterEvent.NavigateSetting) } } private suspend fun checkLimit() { val count = _state.value.count if (count >= 10 ) { _event.send(CounterEvent.ShowToast( "10に到達しました" )) } } } // CounterScreen.kt: イベントをChannelで統一的に購読し、画面遷移やToastを一元的に処理 LaunchedEffect( Unit ) { viewModel.event.collect { event -> when (event) { is CounterEvent.ShowToast -> Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() CounterEvent.NavigateSetting -> onNavigateSetting() } } } 私たちのMVVMアーキテクチャの改善方針を運用できるか ここまで紹介した改善方針は、SSOTに基づくState集約、UDFに基づくAction定義、Channelによるイベント通知の統一です。これらは既存のMVVMアーキテクチャの枠組みで実現できることがわかりました。しかしルールとして定めるだけでは、複数人開発の中で徐々に形骸化していくことが課題としてありました。 UiStateにまとめるルールがあっても、急ぎの対応で新しいStateFlowが追加され、元の設計に戻ってしまう ユーザー操作ごとにメソッドを定義する方針でも、View側から複数メソッドを直接呼び出す実装がレビューをすり抜けてしまう Channelに統一するルールがあっても、既存コードを参考にStateFlowでイベント通知を実装してしまう また改善方針を各画面で愚直に実装すると、StateFlowやChannelの定義・購読といったボイラープレートが画面ごとに増加することも課題でした。 MVIアーキテクチャの導入と独自ライブラリの作成 これらの課題から、ルールではなく仕組みとして正しい実装に導かれるよう、MVIアーキテクチャを導入することにしました。 MVIアーキテクチャの導入にあたり、既存のOSSライブラリも検討しました。しかし私たちが必要としているのはシンプルなMVIのデータフローであり、既存のOSSライブラリは多機能で学習コストが高いと感じました。実現に必要なコード量も少なく自分たちで開発できる規模だったため、プロジェクトの特性に合わせた独自MVIライブラリを作成することにしました。 データフロー 独自MVIライブラリでは、前述の改善方針をMVIの設計思想に沿って整理することにしました。MVIのState・View・Actionに加えて、画面遷移やToast表示といった一度限りのイベントを扱うSideEffectを導入しています。 要素 役割 対応する改善方針 State 画面の現在状態を表す単一のdata class。UIはこの値のみから構築される。 SSOTに基づくState集約 View Stateを受け取って画面に反映し、ユーザー操作をActionとして発行する。 - Action ユーザー操作をViewからViewModelへ伝える入力。 UDFに基づくAction定義 SideEffect 画面遷移やToast表示など、一度限りのイベント。ChannelでViewに配信される。 Channelによるイベント通知統一 ViewからActionが送信されると、ViewModelがそれを受け取ってStateを更新するか、SideEffectを発行します。このシンプルなデータフローにより、ユーザー操作がどのように処理されるかを一貫した流れで追えるようにしています。 実装 インタフェースの定義 まず、MVIの各要素に対応するマーカーインタフェースとして MVIState ・ MVIAction ・ MVISideEffect を定義しました。各画面のState・Action・SideEffectクラスへこれらを実装させることで、型パラメータの制約として利用し、誤った型の組み合わせをコンパイル時に検出できます。 次に、MVIのデータフローを実現するための MVI インタフェースを定義しました。Stateの購読( state )、Actionの受け取り( onAction )、Stateの更新( update )、SideEffectの発行( sideEffect )を集約しています。 interface MVIState interface MVIAction interface MVISideEffect interface MVI<State : MVIState, Action : MVIAction, SideEffect : MVISideEffect> { val state: StateFlow<State> val currentState: State val sideEffect: Flow<SideEffect> fun onAction(action: Action) suspend fun update(block: suspend (State) -> State) suspend fun sideEffect(effect: SideEffect) } 移譲を用いたインタフェースの実装 次に、このインタフェースの実装クラスとして MVIDelegate を用意しました。内部ではStateをMutableStateFlowで管理し、SideEffectをChannelで配信しています。ViewModelではKotlinのデリゲートパターン( by mvi(...) )を使うことで、 MVI インタフェースの機能をViewModelへ追加できるようにしました。 class MVIDelegate<State : MVIState, Action : MVIAction, SideEffect : MVISideEffect>( initialState: State, ) : MVI<State, Action, SideEffect> { private val _state = MutableStateFlow(initialState) override val state: StateFlow<State> = _state.asStateFlow() override val currentState: State get () = _state.value private val _sideEffect by lazy { Channel<SideEffect>() } override val sideEffect: Flow<SideEffect> by lazy { _sideEffect.receiveAsFlow() } override fun onAction(action: Action) {} override suspend fun sideEffect(effect: SideEffect) { ... } override suspend fun update(block: suspend (State) -> State) { ... } } fun <State : MVIState, Action : MVIAction, SideEffect : MVISideEffect> mvi( initialUiState: State, ): MVI<State, Action, SideEffect> = MVIDelegate( initialState = initialUiState, savedStateHandle = null , savedStateName = null , ) また、Jetpack ComposeとMVIを接続するための MviContent コンポーザブルも提供しています。内部でStateとSideEffectを購読し、Content層には state と onAction のみが渡されます。開発者は購読の仕方を意識せず純粋なComposable関数を書くだけで済むようにしました。 @Composable fun <State : MVIState, Action : MVIAction, SideEffect : MVISideEffect> MviContent( viewModel: MVI<State, Action, SideEffect>, sideEffect: suspend (SideEffect) -> Unit , content: @Composable (state: State, onMviAction: (Action) -> Unit ) -> Unit , ) { LaunchedEffect( Unit ) { viewModel.sideEffect.collect { sideEffect(it) } } val state by viewModel.state.collectAsStateWithLifecycle() content(state, viewModel :: onAction) } MVIアーキテクチャを独自MVIライブラリで実装する ここからは、独自MVIライブラリを使って実際にCounter画面をMVIアーキテクチャで実装した例を紹介します。Contract・ViewModel・Screen・テストの順に、改善方針がどのようにコードに反映されるかを確認していきます。 Contract: State・Action・SideEffectの定義 画面に必要なState・Action・SideEffectを、1つのContractファイルにまとめて定義します。SSOTの原則に従い画面の状態を CounterState に集約し、UDFの原則に従いユーザー操作を CounterAction として列挙しています。一度限りのイベントは CounterSideEffect として定義します。画面が扱うデータの全体像がこのファイルだけで把握できます。 // CounterContract.kt // SSOT: 画面の状態を1つのdata classに集約 data class CounterState( val count: Int = 0 , ) : MVIState { val doubleCount: Int get () = count * 2 val tripleCount: Int get () = count * 3 companion object { val initialState = CounterState() } } // UDF: ユーザー操作をActionとして型で定義 sealed class CounterAction : MVIAction { data object Increment : CounterAction() data object Decrement : CounterAction() data object Reset : CounterAction() data object ClickSetting : CounterAction() } // Channel: 一度限りのイベントと画面遷移をSideEffectとして定義 sealed class CounterSideEffect : MVISideEffect { data class ShowToast( val message: String ) : CounterSideEffect() data object NavigateSetting : CounterSideEffect() } ViewModel: Actionの処理とState更新 ViewModelでは MVI インタフェースをデリゲートパターン( by mvi(...) )で利用します。 by mvi() を使うことでStateFlowを用いたState管理とChannelを通じたSideEffect配信がライブラリ側で強制されるため、開発者が独自にFlowを定義する余地がなくなります。すべてのユーザー操作は onAction で一元的に受け取ります。Actionの種類に応じて update でStateを更新し、 sideEffect を通じてイベントを送信します。 // CounterViewModel.kt @HiltViewModel class CounterViewModel @Inject constructor () : ViewModel(), MVI<CounterState, CounterAction, CounterSideEffect> by mvi(CounterState.initialState) { override fun onAction(action: CounterAction) { viewModelScope.launch { when (action) { CounterAction.Increment -> reduceIncrement() CounterAction.Decrement -> reduceDecrement() CounterAction.Reset -> reduceReset() CounterAction.ClickSetting -> sideEffect(CounterSideEffect.NavigateSetting) } } } private suspend fun reduceIncrement() { update { it.copy(count = it.count + 1 ) } checkLimit() } private suspend fun reduceDecrement() { update { it.copy(count = it.count - 1 ) } } private suspend fun reduceReset() { update { CounterState.initialState } } private suspend fun checkLimit() { val count = currentState.count if (count == 10 ) { sideEffect(CounterSideEffect.ShowToast( "10に到達しました" )) } } } ViewからActionが送信され、 onAction 内でそのActionに対する処理がすべて完結します。View側が複数メソッドを組み合わせて呼び出す必要がなくなり、呼び忘れや順序ずれが構造的に発生しなくなります。画面遷移もSideEffectとして onAction 内から発行されるため、遷移の起点がViewModel側に集約されます。 View: MviContentによるCompose連携 この例では、View層をScreenとContentに分けて実装しています。Screenでは MviContent を使ってStateの購読とSideEffectの処理を接続します。 MviContent の内部でStateとSideEffectの購読が行われるため、Contentには state と onAction のみが渡されます。ContentはStateを表示してActionを送信するだけの純粋なComposable関数になります。 // CounterScreen.kt @Composable fun CounterScreen( mvi: MVI<CounterState, CounterAction, CounterSideEffect>, onNavigateSetting: () -> Unit , modifier: Modifier = Modifier, ) { val context = LocalContext.current MviContent( viewModel = mvi, sideEffect = { effect -> when (effect) { is CounterSideEffect.ShowToast -> Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() CounterSideEffect.NavigateSetting -> onNavigateSetting() } }, ) { state, onAction -> CounterScreenContent( state = state, onIncrement = { onAction(CounterAction.Increment) }, onDecrement = { onAction(CounterAction.Decrement) }, onReset = { onAction(CounterAction.Reset) }, onSettingClick = { onAction(CounterAction.ClickSetting) }, modifier = modifier, ) } } @Composable private fun CounterScreenContent( state: CounterState, onIncrement: () -> Unit , onDecrement: () -> Unit , onReset: () -> Unit , onSettingClick: () -> Unit , modifier: Modifier = Modifier, ) { Column(modifier = modifier) { Text(text = "Count: ${ state.count } " , fontSize = 32 .sp) Button(onClick = onIncrement) { Text(text = "+" ) } Button(onClick = onDecrement) { Text(text = "-" ) } Button(onClick = onReset) { Text(text = "Reset" ) } Button(onClick = onSettingClick) { Text(text = "Setting" ) } } } Flowごとに collectAsState を並べる必要がなくなり、View側がnavControllerやViewModelの内部状態に依存する構造も解消されます。画面遷移やToast表示はすべてSideEffect経由のコールバックに統一されるため、Contentの責務がシンプルに保たれます。ViewModelに依存しないComposable関数を用意することで、Preview関数も定義しやすくなります。 テスト: Actionを送信してState・SideEffectを検証 MVIアーキテクチャではデータフローが一方向に固定されているため、テストも「Actionを送信して、Stateの変化またはSideEffectの発行を検証する」というパターンに統一されます。テスト対象の入力と出力が明確なので、何をテストすべきかが自然と定まります。 // CounterViewModelTest.kt class CounterViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() private lateinit var target: CounterViewModel @BeforeTest fun setup() { target = CounterViewModel() } // Stateの変化を検証 @Test fun `Action - Increment - increases count by 1`() = runTest { target.state.test { assertEquals( 0 , awaitItem().count) target.onAction(CounterAction.Increment) val state = awaitItem() assertEquals( 1 , state.count) assertEquals( 2 , state.doubleCount) assertEquals( 3 , state.tripleCount) } } // SideEffectの発行を検証 @Test fun `Action - ClickSetting - emits NavigateSetting side effect`() = runTest { target.sideEffect.test { target.onAction(CounterAction.ClickSetting) assertEquals(CounterSideEffect.NavigateSetting, awaitItem()) } } // State更新とSideEffectの組み合わせを検証 @Test fun `Action - Increment - emits ShowToast when count reaches 10`() = runTest { repeat( 9 ) { target.onAction(CounterAction.Increment) } target.sideEffect.test { target.onAction(CounterAction.Increment) assertEquals(CounterSideEffect.ShowToast( "10に到達しました" ), awaitItem()) } } } MVVMアーキテクチャからMVIアーキテクチャに移行してみて このような独自MVIライブラリを使ったMVIアーキテクチャへの移行は2024年9月に開始しました。既存画面を一括で移行するのではなく、「新規画面は原則MVI」「既存画面は改修タイミングで置き換え」というルールにより画面単位で段階的に進めています。これにより開発を止めることなく移行を進められ、画面ごとのリスクを小さく保ったまま適用範囲を広げることができており、2026年2月現在も段階的な移行を継続しています。 2024年9月 2025年4月 2025年10月 現在 MVI 1(2.2%) 11(24.4%) 21(38.9%) 31(50.8%) MVVM 44(97.8%) 34(75.6%) 33(61.1%) 30(49.2%) 合計 45 45 54 61 このようにMVIの実装が徐々に増える中で、前述のアーキテクチャ上の課題が解消されたことに加え、開発工程そのものにも以下のようなメリットが出てきています。 チーム全体で一貫した実装ができるようになった 独自MVIライブラリを作り実装方針を決め、あわせてドキュメントを整備・公開したことで、ライブラリとドキュメントの両面からチーム全体で一貫した実装を進められるようになりました。 新しいメンバーが加わった際も、1つの画面のContract・ViewModel・Viewを読めばプロジェクト全体の実装パターンを理解できます。オンボーディングの負荷も軽減されていると感じています。 PRレビューの質が向上した チーム全体で実装方針を統一できるようになり、基本的なデータフローに関する指摘は大きく減りました。以前は、実装パターンの統一に関するコメントがレビューの多くを占めていました。MVIライブラリによってこれらが構造的に解消されたことで、レビューの焦点が変わりました。現在は、仕様の妥当性の確認やコードのブラッシュアップに、より多くの時間を使えるようになりました。 AIコーディングエージェントとの協業がしやすくなった 現在、AIコーディングエージェントのDevinを活用した既存画面のMVI移行にもチャレンジしています。MVIアーキテクチャではState・Action・SideEffectという明確な構造があるため、Devinが生成したコードでも処理の流れを追いやすく、レビューしやすいです。アーキテクチャが統一されていることは、人間同士の開発だけでなく、AIとの協業においても大きなメリットになると感じています。 まとめ 本記事では、ZOZOFITのAndroidアプリにおけるMVVMアーキテクチャの課題と、MVIアーキテクチャへの移行、独自MVIライブラリの開発について紹介しました。MVIアーキテクチャは、ユーザー体験の低下を未然に防ぐ仕組みとしても機能していると感じています。ZOZOFITの利用者が日々増えるなかでも体験を安定して支えられるよう、これからもアーキテクチャの改善を進めていきます。最後までお読みいただき、ありがとうございました。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、FAANS部フロントエンドブロックの 加藤 です。普段はFAANSのiOSアプリを開発しています。FAANSは、ショップスタッフの販売サポートツールであり、アプリ上でコーディネートの投稿や売上などの成果を確認できます。 成果の確認画面では以下の動画のように成果を棒グラフで可視化しています。これまでFAANS iOSでは、棒グラフの生成にサードパーティライブラリである DGCharts を用いていました。一方で、FAANSではiOS 15のサポートを終了しているため、iOS 16以上で利用可能なApple標準のグラフ生成フレームワーク「Swift Charts」を利用できます。そこで、この度、DGChartsからSwift Chartsへの移行を実施しました。 この記事では、DGChartsからSwift Chartsへの移行にあたり検討した実装アプローチについて紹介します。 目次 はじめに 目次 成果画面のレイアウトと機能 Swift Chartsのみで実装 Swift Charts + UICollectionViewで実装 Swift Charts + 表示データの工夫で実装 DGChartsとSwift Chartsの比較 まとめ さいごに 成果画面のレイアウトと機能 FAANSにおける成果画面のレイアウトと機能は以下の画像のようになっています。 成果画面では、横軸が日付、縦軸が売上の棒グラフが表示されます。棒グラフは横方向のスクロール(画像の1)、およびタップが可能で、選択した日付の売上が画面上に表示される仕組みです(画像の2)。また、棒グラフは3〜4種類の値で構成されており、それぞれの値を色分けして積み上げています(画像の3)。さらに、棒グラフは1画面に7.5日分表示されており、左端に0.5日分が見切れた状態です。これにより、スクロールが可能であることを示唆しています(画像の4)。 以上がFAANSの成果画面におけるレイアウトと機能です。本記事では、これらの機能をSwift Chartsで実装するにあたり検討した3つのアプローチについて、比較・検証した過程を紹介します。 実装方法は以下の3つです。 Swift Chartsのみで実装する方法 Swift ChartsとUICollectionViewを組み合わせて実装する方法(今回採用した方法) 表示するデータを工夫したSwift Chartsの実装方法(採用には至らなかったが、Swift Chartsのみで完結させる代替案として紹介) また、実装要件と3つの実装方法に対する評価方法は以下の通りです。 実装要件 横スクロール、タップアクション、値の積み上げ、7.5日分の表示の4種類の機能を実装する 評価方法 InstrumentsのHitches(フレームの描画遅延の回数・タイミングを可視化するツール) 検証端末:iPhone 16 Pro(iOS 26.2.1) Swift Chartsのみで実装 まずはSwift Chartsのみで実装する方法についてです。プログラムは以下の通りです。 // グラフデータの構造体 struct Sales : Identifiable { var id = UUID() var type : String var date : Date var sales : Double } private let salesChannels = [ "zozotown" , "wear" , "yahoo!Shopping" , "ownedEc" ] //------以下、グラフの生成 struct BarChartsView : View { private let visibleLength : TimeInterval = 24 * 60 * 60 * 7.5 private let dateFormatter = DateFormatter(with : .weeklyChart) // 自作の拡張 // データの作成 private let barData : [ Sales ] = [ (month : 9 , days : 1 ... 30 ), (month : 10 , days : 1 ... 30 ) ].flatMap { month, days in days.flatMap { day -> [ Sales ] in salesChannels.map { type in Sales( type : type , date : date (year : 2025 , month : month , day : day ), // Dateの作成 sales : round (Double.random( in : 0 ... 50000000 )) ) } } } @State private var scrollPosition : Date = barData.last ! .date var body : some View { Chart(barData, id : \.id) { row in BarMark( x : .value( "Day" , row.date, unit : .day), // x座標のデータ(日付) y : .value( "Sales" , row.sales) // y座標のデータ(売上) ) .foregroundStyle(by : .value( "Type" , row.type)) // ③データの積み上げ } .chartScrollableAxes(.horizontal) // ①横方向のスクロール方向(iOS 17+) .chartLegend(.hidden) // 凡例の非表示 .chartXVisibleDomain(length : visibleLength ) // ④可視化幅を7.5日分に設定(iOS 17+) .chartScrollPosition(x : $scrollPosition ) // 最初に右端が映るように設定(iOS 17+) // 積み上げる色の定義 .chartForegroundStyleScale([ "zozotown" : Color (.Token.serviceZozotown), "wear" : Color (.Token.serviceWear), "yahoo!Shopping" : Color (.Token.serviceYahoo), "ownedEc" : Color (.Token.serviceBrandEc) ]) // ②グラフタップ時の挙動(iOS 17+) .chartGesture { chart in SpatialTapGesture() .onEnded { value in guard let (date, _) = chart.value( at : value.location , as : ( Date , Double ) . self ) else { return } // ↑dateがタップした日付 } } // x軸のラベル定義 .chartXAxis { AxisMarks(values : .stride(by : .day)) { value in if let date = value. as ( Date.self ) { AxisValueLabel(centered : true ) { Text(dateFormatter.string(from : date )) // MM/dd\nEEE .multilineTextAlignment(.center) } } } } // y軸のラベル定義 .chartYAxis { AxisMarks(values : .automatic(desiredCount : 4 )) { value in AxisValueLabel(multiLabelAlignment : .leading) { if let raw = value. as ( Double.self ) { Text( // 中身は省略 ) } } } } } } 上記プログラムでは、横スクロール、タップアクション、値の積み上げ、7.5日分の表示の4種類の機能をそれぞれ以下の方法で実装しています。 横スクロール: chartScrollableAxes(.horizontal) タップアクション: chartGesture 値の積み上げ: foregroundStyle 7.5日分の表示: chartXVisibleDomain 注意が必要なのは、 chartScrollableAxes と chartGesture はiOS 17以降で利用できる機能である点です。また、 chartScrollPosition で初期の表示位置を指定している点や、chartXAxisやchartYAxisで目盛りのレイアウトを調整している点も重要です。 これで、実装したかった成果画面のレイアウトと機能を全て実装できました。しかし、スクロール時の動作を確認してみると、スクロールが重たく感じます。主観では判断できないため、InstrumentsのHitchesを用いてパフォーマンスを計測しました。パフォーマンス計測では、グラフの表示画面を表示して、数回のスクロールを実施しました。パフォーマンス計測結果は以下の画像のようになりました。 上記画像におけるタイムライン上の赤線は、フレームの描画遅延が発生した時刻を表しています。Swift Chartsのみの実装では赤線が密集しており、スクロール中に連続してフレームの描画遅延が発生していることが確認できました。また、サマリーを見ると338回発生しており、最大Hitchは25msでした。ここで比較のため、DGChartsを用いた既存実装におけるHitchesを示します。 Swift Chartsのみで実装した場合と比較して、赤線が密集している箇所が少なく、最大Hitchも12.50msであることが分かります。 Swift Chartsのみで実装された場合におけるパフォーマンス低下の原因を調査した結果、データ数の多さ(約2か月分)が主な要因のようです。また、 multilineTextAlignment(.center) の指定や、 chartScrollPosition の利用も影響していました(正確な原因の特定には至りませんでした)。 multilineTextAlignment(.center) をやめると軽くなりますが、データ数は減らせないので、Swift Chartsのみの実装方法は採用しませんでした。 Swift Charts + UICollectionViewで実装 Swift Chartsにおけるスクロールのパフォーマンス問題を解消するために、UICollectionViewを用いる方法を検討しました。具体的には、UICollectionViewの scrollDirection で横スクロールを実現して、UICollectionViewCellとしてSwift Chartsを表示します。UICollectionViewはUICollectionViewCellを再利用して描画するため、データ量が多い場合でもパフォーマンスへの影響を抑えられます。これまでのDGChartsを用いた実装でも、この方法を採用していました。 また、UICollectionViewを用いた実装では、y軸を別途実装する必要があります。FAANSの成果画面では右端にy軸が固定されており、棒グラフのみがスクロールできるデザインです。そのため、UICollectionViewCellに載せるViewではy軸は非表示にして、別のViewとして実装する必要があります。図にすると下記のような構成です。 UICollectionViewCellに載せるSwift Chartsの実装は以下の通りです。 // 表示するデータのチャンネル enum StackedOutcomeChannel : String , Plottable, CaseIterable { case zozotown case wear case yahooShopping case ownedEc } // グラフデータの構造体 struct StackedOutcomeBarMarkEntry : Hashable { var type : StackedOutcomeChannel var date : Date var value : Double } struct StackedOutcomeBarMarkView : View { // 外部から代入する値 struct ChartModel { var colors : [ UIColor ] var entries : [ StackedOutcomeBarMarkEntry ] var yAxisMax : Double var selectedDate : Date? var onSelectDate : (( Date ) -> Void ) ? } let chartModel : ChartModel @State private var selectDate : Date? // 選択されたグラフ日時の格納先 // グラフの色(chartForegroundStyleScaleで利用するためにKeyValuePairsで定義) private var barMarkColors : KeyValuePairs < StackedOutcomeChannel , Color > { return [ StackedOutcomeChannel.zozotown : Color (chartModel.colors[ 0 ]), StackedOutcomeChannel.wear : Color (chartModel.colors[ 1 ]), StackedOutcomeChannel.yahooShopping : Color (chartModel.colors[ 2 ]), StackedOutcomeChannel.ownedEc : Color (chartModel.colors[ 3 ]) ] } init (chartModel : ChartModel ) { self .chartModel = chartModel _selectDate = State(initialValue : chartModel.selectedDate ) } var body : some View { Chart(chartModel.entries, id : \. self ) { row in BarMark( x : .value( "Day" , row.date), y : .value( "Value" , row.value) ) .foregroundStyle(by : .value( "Type" , row.type)) } .chartLegend(.hidden) // 凡例の非表示 .chartForegroundStyleScale(barMarkColors) // 積み上げる色の定義 // グラフタップ時の挙動(iOS 17+) .chartGesture { chart in SpatialTapGesture() .onEnded { value in guard let (date, _) = chart.value( at : value.location , as : ( Date , Double ) . self ) else { return } self .selectDate = date chartModel.onSelectDate?(date) } } // x軸のラベル定義 .chartXAxis { AxisMarks(values : .stride(by : .day)) { value in if let date = value. as ( Date.self ) { AxisValueLabel(centered : true ) { Text(DateFormatter(with : .weeklyChart).string(from : date )) .multilineTextAlignment(.center) } } } } .chartYScale(domain : 0 ... chartModel.yAxisMax) // 重要: y軸スケールの定義 .chartYAxis(.hidden) // y軸の非表示 } } Swift Chartsのみで実装した場合と異なり、横スクロールの設定や chartScrollPosition による初期位置の調整は不要です。また、y軸は非表示にしたいので、 .chartYAxis(.hidden) を設定しています。このとき、 chartYScale を用いて、y軸の最小値と最大値を設定しておくことがポイントです。この定義で、独立したy軸のみのViewと棒グラフの目盛りの整合性を取ります。 続いて、右側に固定するy軸のViewを下記のプログラムで実装します。 struct BarMarkYAxis : View { // 外部から代入する値(仕様の関係) final class YAxisModel : ObservableObject { @Published var yAxisMax : Double = 100 } @ObservedObject var model : YAxisModel = YAxisModel() var body : some View { Chart { // y軸最大値のルールの定義(あってもなくてもよい) RuleMark(y : .value( "max" , model.yAxisMax)) .foregroundStyle(.clear) } .chartXAxis(.hidden) // x軸の非表示 .chartYScale(domain : 0 ... model.yAxisMax) // y軸範囲の定義 // y軸のラベル定義 .chartYAxis { // おおよそ6つの目盛りで構成 AxisMarks(values : .automatic(desiredCount : 6 )) { value in // 補助線の非表示化 AxisGridLine(stroke : StrokeStyle (lineWidth : 0 )) AxisValueLabel(multiLabelAlignment : .leading) { if let raw = value. as ( Double.self ) { Text( // 中身は省略 ) } } } } .chartPlotStyle { plot in plot.frame(width : 0 ) // y軸だけ欲しいのでグラフのプロット幅を0に } .frame(width : 39 ) } } このプログラムでは、 chartXAxis(.hidden) でx軸を非表示にしており、棒グラフとして表示するデータも与えていません。一方で、これだけではグラフのプロット領域が確保されてしまうので、 chartPlotStyle で plot.frame(width: 0) を定義して、プロット領域の幅を0にしています。また、Swift ChartsのViewと同様に chartYScale を定義しており、 chartYAxis でy軸の目盛りを設定しています。加えて、 chartYAxis 内の AxisMarks(values: .automatic(desiredCount: 6)) で、おおよそ6つの目盛りをy軸上に表示しています。 以上のSwift ChartsのViewをCellとしたUICollectionViewと、Swift Chartsで作成したy軸を組み合わせて実装した成果画面の完成版が下記の動画です。最初に述べたFAANSにおけるレイアウトと機能を実装できていることが確認できます。 また、InstrumentsのHitchesを用いてパフォーマンスを計測した結果、以下の画像のように赤線の密集が少なく、パフォーマンスの著しい低下が発生していないことが確認できました。 Swift Charts + 表示データの工夫で実装 先に述べた通り、Swift Chartsのみの実装では横スクロールが重たく感じる事象を確認したため、UICollectionViewと組み合わせた方法を採用しました。一方で、UICollectionViewを使わずSwift Chartsのみで完結させたいケースもあるかと思います。そこで、一度に渡すデータ量を制限すればスクロール時のパフォーマンス低下を緩和できると考え、試作しました。今回は採用に至りませんでしたが、Swift Chartsのみで実装する際の代替案として紹介します。データ量の制限方法は以下の図の通りです。 図の例では、1/31をデータの最終日とした場合、最初に1/31から1か月前までのデータをSwift Chartsに渡します(図の上段)。その後、ユーザが1/1までスクロールした際には、1/1を中心とした前後15日分、すなわち合計30日分(約1か月)を新たな表示データとしてSwift Chartsに渡します(図の下段)。このように実装することで、Swift Chartsは常に1か月分のデータのみ描画することになり、大量データを渡したときと比較して、スクロールが重くなりにくいと考えられます。実装は下記の通りです。 struct BarChartsView : View { private let visibleLength : TimeInterval = 24 * 60 * 60 * 7.5 private let stopDebounce : TimeInterval = 0.25 private let dateFormatter = DateFormatter(with : .weeklyChart) @State private var scrollPosition : Date = barData.last ! .date // barDataは1つ目の実装例と同様の定義 @State private var scrollStopTask : Task < Void , Never > ? @State private var visibleData : [ Sales ] = [] // 表示するデータを格納(1か月分) @State private var pendingScrollTarget : Date? @State private var isProgrammaticScroll = false @State private var chartEpoch : Int = 0 init () { let center = barData.last ! .date _visibleData = State(initialValue : extractWindowData (around : center )) } var body : some View { Chart(visibleData, id : \.id) { row in BarMark( x : .value( "Day" , row.date, unit : .day), y : .value( "Sales" , row.sales) ) .foregroundStyle(by : .value( "Type" , row.type)) } .id(chartEpoch) // visibleData差し替え時にChartも再構築 .chartScrollableAxes(.horizontal) .chartLegend(.hidden) .chartXVisibleDomain(length : visibleLength ) .chartScrollPosition(x : $scrollPosition ) // スクロール時に左端のグラフが見切れる位置で止まるように制御(iOS 17+) .chartScrollTargetBehavior( .valueAligned(matching : DateComponents (hour : 12 , minute : 0 , second : 0 )) ) .chartForegroundStyleScale([ // (省略) ]) .chartXAxis { // (省略) } .chartYAxis { // (省略) } .onChange(of : scrollPosition ) { _, newValue in // 自動スクロールでscrollPositionが更新された場合、scrollStopCheckを呼ばない if isProgrammaticScroll { isProgrammaticScroll = false return } // ユーザ操作でスクロールされた際に呼び出し scrollStopCheck(after : stopDebounce ) } // 表示するデータの差し替え後に、差し替え前に表示していた位置に遷移 .onChange(of : visibleData ) { _, _ in guard let target = pendingScrollTarget else { return } pendingScrollTarget = nil Task { @MainActor in isProgrammaticScroll = true scrollPosition = target } } } // グラフがスクロールされた場合の処置 func scrollStopCheck (after delay : TimeInterval ) { scrollStopTask?.cancel() scrollStopTask = Task { @MainActor in // Task.sleepで待機中に次のタスクが来たら前のタスクをキャンセル do { try await Task.sleep(nanoseconds : UInt64 (delay * 1_000_000_000 )) } catch { return } guard ! Task.isCancelled else { return } let center = alignToNoon(scrollPosition) // データ更新後の遷移先の指定 let next = extractWindowData(around : center ) // 新たなデータの抽出(centerを中心として前後15日のおよそ1か月分) chartEpoch += 1 // idの更新 visibleData = next // 表示するデータ位置の更新 let pendingPosition = Calendar.current.date(byAdding : .day, value : 1 , to : center ) ! pendingScrollTarget = pendingPosition // データ更新後の遷移位置の指定 } } // 引数: centerの値から前後15日分の1か月分を親配列から抽出 func extractWindowData (around center : Date , days : Int = 15 ) -> [ Sales ] { let cal = Calendar.current let start = cal.date(byAdding : .day, value : - days, to : center ) ?? center let end = cal.date(byAdding : .day, value : days , to : center ) ?? center return barData.filter { $0 .date >= start && $0 .date <= end } } // 入力されたDateの時間を12時に固定 func alignToNoon (_ date : Date ) -> Date { var comps = Calendar.current.dateComponents([.year, .month, .day], from : date ) comps.hour = 12 comps.minute = 0 comps.second = 0 return Calendar.current.date(from : comps ) ?? date } } 上記プログラムのポイントは、以下の3つです。 chartScrollPosition によるスクロールの監視 表示データとChartのidの更新 scrollPosition によるグラフ位置の調整 まず、 chartScrollPosition に scrollPosition の変数を設定して、現在のスクロール位置を監視します(ポイント1)。スクロールがあった場合には、 onChange(of: scrollPosition) が呼ばれ、内部に定義されている scrollStopCheck(after: stopDebounce) が呼ばれます。この関数では、スクロール後、一定の時間静止した場合に表示データを更新します。更新後のデータは、 extractWindowData という自作の関数を用いて取得しています。また、データの更新時には chartEpoch を更新してChart自体を新しく構築し直す必要があります(ポイント2)。Chartを再構築しない場合、データを更新する度に、Chartのスクロールが重くなっていきます。 最後にデータを更新した際の表示位置を調整します。表示位置を調整せず、データの更新のみを行った場合、更新前に表示されていた日付からずれます。これは、Swift Chartsがスクロール位置を座標として記録しているためです。例えば、先ほどの図の上段において1/1までスクロールしたとします。すなわち、左端のデータが表示されている状態です。この状態で図の下段のようにデータを更新すると、左端のデータがそのまま表示されるので、1/1ではなく、12/16が表示されてしまいます。データ更新後も1/1が表示されている状態を維持したいので、データ更新前の表示位置をあらかじめ記録します。上記プログラムでは、 pendingScrollTarget に表示位置を記録しています。そして、記録した表示位置を用いて、 scrollPosition を更新することでデータ更新後の表示位置を調整します。 また、InstrumentsのHitchesを用いてパフォーマンスを計測した結果、下記画像に示すように赤線の密集が発生していません。すなわち、データの量を制限していない場合と比較して、大幅にパフォーマンスを改善できていることが確認できました。 このプログラムを用いることでSwift Chartsのみで実装できます。一方で、 chartScrollPosition はスクロール位置の同期が主な用途です( 公式ドキュメント )。そのため、データ差し替え後の位置制御に用いる場合は意図しない挙動が発生するかもしれません。また、端までスクロールした際にデータを更新すると、見切れている棒グラフとの位置関係によるグラフのずれが発生します。採用には注意が必要です。 DGChartsとSwift Chartsの比較 最後に、Swift Chartsへの置き換えで学んだDGChartsとSwift Chartsの違いを表で示します。基本的にはApple純正のフレームワークであるSwift Chartsを用いるのが良いと考えています。 項目 DGCharts Swift Charts フレームワーク種別 サードパーティ Apple純正 対応OS iOS 12+ iOS 16+ UI基盤 UIKit SwiftUI 積み上げ棒グラフの実現方法 x座標を指定して、積み上げる値の配列を渡す 配列内でx座標が同じ要素を重ねて表示 グラフのハイライト色指定 highlightAlphaで色の指定 専用の色指定APIはない スクロール挙動の制御 スナップやページングは自前実装が必要 .chartScrollTargetBehaviorで単位揃えやスナップを指定可能(iOS 17+) 大量データのスクロール(パフォーマンスの問題) UICollectionViewのセル再利用により、大量データでもパフォーマンスの問題は発生しにくい 標準の横スクロール(chartScrollableAxes)では大量データで描画遅延が発生。UICollectionViewとの併用や表示データ量の制限で対処が必要 まとめ 本記事では、DGChartsからSwift Chartsへの移行にあたり、3つの実装アプローチを比較・検証した過程を紹介しました。 Swift Chartsは宣言的な記述で手軽にグラフを実装できる一方、大量データのスクロール描画ではパフォーマンス上の課題があります。そのため、UICollectionViewとの併用やデータの動的な差し替えといった工夫が求められる場面もあります。今回はUICollectionViewとの組み合わせを採用しましたが、要件やデータ量に応じて最適な方法は異なるため、本記事で紹介した各アプローチが実装方針の判断材料になれば幸いです。 さいごに ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは。グローバルシステム部 バックエンドブロックの髙橋と松浦です。私たちはZOZOMETRY・ZOZOMAT・ZOZOGLASSなどのシステムを開発、運用しています。 今回、エンジニアリング全般の知見を深めるため、2026年2月21日にオーストラリア・メルボルンで開催された DDD Melbourne に参加しました。この記事ではDDD Melbourneに現地参加した経験や、セッションを通じて学んだ内容を紹介します。 はじめに DDD Melbourneとは 現地の様子 気になったセッション紹介 How To Write Awful Unmaintainable Code 副作用 技術的負債 まとめ The Safety First App: 12 product patterns that turn failures into recoveries Autosave + draft states Explainable errors まとめ Managing for Failure 心に残ったポイント まとめ Throw Away The Vibes: Context Engineering Is All You Need コンテキストの4つの失敗モード Breadcrumb Protocol Research → Plan → Implement → Review(RPIR)フロー コンテキストサイズの60%ルール まとめ 最後に DDD Melbourneとは DDD Melbourneは、非営利団体Oz Dev Inc.が主催するソフトウェアコミュニティのカンファレンスです。 このカンファレンスでの「DDD」とは「Developer! Developer! Developer!」の略で、英国・オーストラリア・ヨーロッパ各地で開催されており、ソフトウェアに関わるさまざまな業種の方が、開発についてのプラクティスを共有しています。 セッションのテーマは毎年コミュニティの投票によって決定されるため、今年のトレンドや関心事が反映されているのも、このカンファレンスのポイントです。登壇者にはエンジニア、デザイナー、プロダクトマネージャーなど多様なバックグラウンドを持つ方々がいます。そのため、実践的な知見や生々しい失敗談など、現場のリアルな声を聞けることが特徴です。今回発表された内容も、AIについての問題点や開発手法から、エンジニアとしてのマインドセットまでかなり幅広く、発表者の興味や経験が伝わってくる内容でした。 現地の様子 本カンファレンスは150年以上の歴史を持つMelbourne Town Hallで行われました。 Melbourne Town Hall オープニング前の様子 オープニングの様子 気になったセッション紹介 How To Write Awful Unmaintainable Code このセッションでは、ベストプラクティスとアンチパターンを対比して、何が悪いかをコントのように共有していたのが印象的でした。 前半では、命名・バージョニング・コミットといった基本的なテーマから始まり、コミットを小さく保つことやセマンティックバージョニングを正しく使うことの重要性が語られていました。対比として、政治的・組織的にバージョンが決められるEnigmatic Versioningの話がスライドに出され、会場も笑いに包まれました。 Enigmatic Versioning 後半は、副作用と技術的負債が主な話題でした。 副作用 良いコードとは「関数が1つのことだけを行い、関数名が正確にそのことだけを説明している」という定義の話から入り、無害に見える関数が副作用を持っていたという例が挙げられていました。 意図した副作用は時に必要ですが、基本的には避けるのがベターだと考えますし、名前や仕組みで判断できないのはバグの原因になりかねないので、改めて注意が必要だと認識しました。 技術的負債 米国の金融企業Knight Capitalは2012年、高値で買い、安値で売るロジックを含むデッドコードが本番環境で動作してしまいました。約45分間で約4億4,000万ドルの損失を出し、最終的にGetco社との合併に至りました。近しい金額のNASAの火星探査機喪失と比較されるほど、デッドコードの危険性を示す象徴的な事例として紹介されていました。 まとめ あるあるネタから始まりつつ、後半になるにつれて話の重みがどんどん増していきました。Knight Capitalの事例は特に衝撃的で、コードの管理不足が会社の経営危機にまで繋がってしまった話はとても印象的な事例でした。技術的負債や構造の問題は、放置すれば取り返しのつかない事態になり得るため、改めてコード管理には気をつけたいと再認識したセッションでした。 The Safety First App: 12 product patterns that turn failures into recoveries このセッションは、「ほとんどのアプリケーションは晴れの日のために作られている」という一言から始まりました。しかし、ユーザーは必ずミスをしますし、ネットワークは揺らぎます。APIはタイムアウトします。そして人は焦ると間違ったボタンを押します。それでもなお、多くのプロダクトは正常系という「晴れの日」だけを前提に設計されています。 このセッションでは、異常系「雨の日」を前提にした設計をどうプロダクトに組み込むかを、12のパターンに分解して紹介していました。単なるUX改善の話にとどまらず、プロダクトマネージャー・デザイナー・エンジニアが共通言語を持つための内容でした。 The Safety First App: 12 product patterns that turn failures into recoveries 以下が本セッションで紹介された12パターンです。 Pattern 1: Undo Everywhere(どこでもUndo) Pattern 2: Auto-save & Draft States(自動保存とドラフト状態) Pattern 3: Guard Destructive Actions(破壊的アクションの防御) Pattern 4: Resilient Forms(回復力のあるフォーム) Pattern 5: Explainable Errors(説明可能なエラー) Pattern 6: Quick Recovery Links(クイックリカバリーリンク) Pattern 7: Degraded States(劣化状態) Pattern 8: Idempotent Actions(べき等アクション) Pattern 9: Long-running Work with Receipts(レシート付き長時間処理) Pattern 10: Outbox & Offline Queue(アウトボックスとオフラインキュー) Pattern 11: Rescue Mode(レスキューモード) Pattern 12: Customer-facing Runbooks(カスタマー向けランブック) この中でも、印象深かったものは以下の2つです。 Autosave + draft states このセッションで重要視されていたのは、意味のある変更ごとにドラフト状態を永続化することでした。保存処理をアプリケーションのインフラとして扱ってほしいということです。例えば長いフォームがあった時、タイムアウトしてデータが消失したら、大きなストレスになります。「また入力し直しだ」と感じた経験は多くの方にあるはずです。自動的にドラフトが保存されていれば、そういったことはなくなり、ユーザーは安心して入力を行えます。 また、現在の状態をユーザーに通知することも重要です。インジケーターがあると、ユーザーは正しく保存されていることを確認できるため、安心につながります。 身近な例だと、ドキュメント編集ツールの「保存しました」「保存中...」といったインジケーターが挙げられます。編集中に保存状態が常に表示されていることで、ユーザーはデータが失われていないことを確認でき、安心して作業を続けられます。 Explainable errors エンジニアならお馴染みのHTTPステータスコードでは、404エラーや500エラーがあると思います。しかしそれらのエラーだけだと、ユーザー目線では何が起きたのかほぼわかりません。ユーザーには可能な限りわかりやすい、システム的な言語ではなくユーザーの言語で返してあげる必要があります。 例えばカード支払いだと 「支払いが失敗しました、別のカードをお試しください」 「△⚪︎の追加に失敗しました。再度試すか、別の方法を使ってください」 などです。これが、 「支払いに失敗しました」 「サーバーでエラーが発生しました」 だけだと、ユーザーは何が間違いかが分からないので、解決しようがありません。 何が発生しており、次にユーザーが何をすべきかを示すことが重要です。リトライすべきか、編集し直すべきか、サポートに連絡すべきか。明確なエラーメッセージを返していると、ユーザーが主体的に問題を解決する糸口になります。サポートやインシデントトリアージを行いやすくするために、問い合わせ用のIDをレスポンスに含めることも大切です。 まとめ Autosave + draft statesは、データを扱うと必ず発生する保存と復元の話ですが、ただ保存と読み込みをするだけでは、ユーザーにとって大変になるケースもあることを再認識させられました。ユーザーが意識しなくても困らない仕組みや、いざという時にいつでも状況を確認できる状態を作っておくと、ユーザー自身で解決できることも増えます。また、仮に問題が起こったとしてもより細かく対応ができると思うので、UIを考える上で今後の開発で意識していきます。 Explainable errorsでは、プロダクト開発の現場で見落とされがちなユーザー向けのエラー通知の改善が紹介されていました。エラーの内容と具体的な対処法が適切に提示されていれば、ユーザー自身で解決できるケースは決して少なくありません。今後の開発では、ユーザーができるだけ自力で問題を解消できるようにするには、どのような情報をどの粒度で見せるべきか、という観点でインタフェースを設計していきたいと感じました。 本セッションでは触れられていませんでしたが、参考例としてMetaのGraph APIがあります。このAPIでは、開発者向けの詳細なエラー情報に加えて、ユーザー向けタイトルとメッセージを返却するフィールドも用意されています。 { " error ": { " message ": " Message describing the error ", " type ": " OAuthException ", " code ": 190 , " error_subcode ": 460 , " error_user_title ": " A title ", " error_user_msg ": " A message ", " fbtrace_id ": " EJplcsCHuLu " } } https://developers.facebook.com/docs/graph-api/guides/error-handling?locale=ja_JP もちろんこれらを行うには、相応の管理コストがかかります。そのため、どこまでを丁寧に設計・運用するかという境界を意識することが重要です。現実的には、ユーザー体験への影響が大きい部分には優先的にコストをかけ、それ以外の内部的な部分は、開発・運用の生産性とのバランスを取りながら設計していく姿勢が大事だと考えています。 今回紹介された12パターンの多くは、障害やユーザーの「ミスが起きてから」ではなく「ミスが起きないようにする」設計の話です。安全機構は後付けではなく、最初から盛り込むべきものであることを、改めて実感しました。 Managing for Failure このセッションは「なぜ失敗が必要なのか? それは、失敗を減らすためだ」という問いかけから始まりました。「失敗がないチームは一見健全に見えるが、学習も、成長も止まっている可能性がある。そこで、失敗を安全に経験させる仕組みをどう設計するか」という話が展開されていきました。 このセッションでは、「失敗を減らすために、あえて失敗を設計する」という話が、精神論ではなくかなり具体的なやり方まで踏み込んで展開されていました。 Managing for Failure 心に残ったポイント 30/15 Fail 30分詰まったら15分離れ、戻っても進まなければ「失敗達成」でヘルプを出す方法が紹介されました。失敗をゴールにすることで、行き詰まったこと自体が「達成」になります。助けを求めるハードルが下がる仕組みです。「失敗をゴールにする」という発想が面白かったです。 Fire Drill 実際の障害が起きる前に、意図的に壊して復旧練習をします。本番で強くなるには、練習で失敗しておくことが重要です。これはインフラでもアプリでも同じだと感じました。 私たちのチームでもカオスエンジニアリングの考え方を取り入れて います。 まとめ 印象的だったのは、「失敗は避けるものではなく、慣れるもの。そして、ちゃんと扱えるようにするもの」という考え方です。「うまくいきすぎているチームは、一見健全に見えるが、挑戦していないだけかもしれない」という指摘も印象的でした。 失敗を減らすために、まずは安全に失敗できる場をつくる。何事も慣れや練度が大切だと実感しました。 Throw Away The Vibes: Context Engineering Is All You Need このセッションでは、主にVibe codingについての出力問題とその解決策を模索する話がされていました。生成されたコードは一見、問題なさそうに見えるのですが、実際にそのコードをプロジェクトで使おうとすると、さまざまな問題を抱えておりそのまま使うことはできないコードになることが多いです。たとえプロンプトをうまく書いても、間違ったコードが出てくることがあります。それは、前提のコンテキストが誤っているから、という話でした。 コンテキストの4つの失敗モード LLMのコンテキストには「Poisoning(汚染)」「Distraction(注意散漫)」「Confusion(混乱)」「Clash(衝突)」という4つの失敗パターンがあります。 コンテキストの4つの失敗モード Poisoning(汚染) これは巷でよく言われている、ハルシネーションがコンテキスト内に含まれている状態を指します。ハルシネーションによる誤った情報がコンテキスト内に残ると、そのスレッドではずっと間違った情報をもとに回答を出力してしまいます。 Distraction(注意散漫) 大きいコンテキストの中に複数の要素が保存されている場合、AIが見る場所を間違えると誤った情報に注目してしまい、出力結果が悪くなってしまいます。 Confusion(混乱) 不必要な情報が存在していると、AIは判断ができなくなります。複数の無関係な情報を1つの文脈と誤認し、出力が不安定になるためです。 Clash(衝突) 矛盾した情報が存在していても、Confusionと同様にAIは判断ができなくなります。 これらを解決するには、単純ですが「適切なタイミングで適切なコンテキストを提供すること」という原則を守ることが大切ということでした。以降は、適切にコンテキストを共有するには、どのようなテクニックがあるかが解説されました。 Breadcrumb Protocol スクラッチパッドとしてMarkdownファイルを作り、エージェントと人間がそこに計画やタスクの進捗を書き込んでいく手法です。セッションが壊れても、このファイルさえあれば新しいセッションで続きから再開できる。間違った判断があれば、ファイルを更新するだけで次回からエージェントが正しい振る舞いをするようになる。 「コンテキストを外部に永続化して、セッションに依存しない」という発想が面白かったです。私たちのチームでも同様のアプローチでエージェントへの指示を管理しているので、共感する部分が多かったです。 Research → Plan → Implement → Review(RPIR)フロー いきなりコードを書かせるのではなく、まずリサーチさせて計画を立て、タスク分割してから実装し、最後にレビューするという流れです。LLMは「やれと言われたこと」は何でもやろうとする。逆に言えば「やるなと言わないとやってしまう」。だからこそ、やることを制約する(constrain)のが安定した出力を得る鍵になる。PRレビューで大量の差分を見るのではなく、RPIRの各ステップで段階的にレビューするという考え方は、実務にすぐ取り入れられそうです。 コンテキストサイズの60%ルール コーディングエージェントのコンテキストサイズが60%を超えたら、圧縮(compaction)するか新しいスレッドを始めるべきだという実践的なアドバイスがありました。100万トークン入るからといって全部使えるわけではなく、一定量を超えるとモデルの出力品質が落ちるという話は、普段の開発でも意識しておきたいポイントです。 まとめ AIコーディングの成果は「モデルの性能」ではなく「コンテキストの質」で決まるという話でした。そして、コンテキストの質は「AI」ではなく、「人間」が設計するものです。AIの性能は日々向上していますが、最終的なコア部分は人間の管理がものを言うという結論が印象的でした。 ツールをあれこれ試すよりも、1つのツールに腰を据えて、コンテキストの渡し方を磨くことが重要です。人間の専門性、足場づくり(scaffolding)、方向づけ(steering)は今後もAIコーディングを行う上で不可欠な技術になっていくと思われます。 最近のAIコーディングエージェントでは、まさにこれらの内容をアシストするための仕組みが、システムの仕様として組み込まれている印象があります。AIが進化していくと、品質部分はますますコンテキストの質が担うことになりそうです。コンテキストの整理はAIだけではなく、自身の頭を整理するという意味でも、もちろん有用なので、うまく整理できる能力を磨いていきたいと思います。 最後に 今回DDD Melbourneに参加し、世界のエンジニアが持つ課題意識と、その対応策を学びました。特に、AI周りはZOZOでも幅広く活用しているため、コンテキストについての話は参考になりました。また、海外カンファレンスへの現地参加を通じて、日本とのカンファレンス文化の違いも体感でき、貴重な経験になりました。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、SRE部カート決済SREブロックの伊藤( @_itito_ )です。普段はZOZOTOWNのカート決済機能のリプレイス・運用・保守に携わっています。また、DB領域でのテックリードを務めており、データベース周りの運用・保守・構築も担当しています。 ZOZOでは全社的に生成AIの活用が推奨されており、SRE部においてもAmazon Q Developer(以下、Q Dev)のPoCを実施しました。 本記事では、Q DevのPoCをどのように実施したのか、PoCを通じて得られた知見なども含めてご紹介します。 目次 はじめに 目次 Amazon Q DeveloperとKiroについて Kiro CLI Kiro IDE PoCの概要 背景 PoC体制 利用状況の取得方法について フィードバックと分析 Kiro CLIの評価 デフォルトモデルの違いによる体感品質の差 AWS操作の利便性について Kiro IDEの評価 仕様駆動開発の活用事例 PoCの総括 Amazon Q Developer ProとKiroのプラン選定について まとめ Amazon Q DeveloperとKiroについて まず本PoCで扱ったツールの全体像を整理します。 Amazon Q Developer (以下、Q Dev)はAWSが提供するAI搭載の開発者向けアシスタントです。コード補完やチャットなどのIDE支援、CLI、AWSリソース管理といった機能を備えています。一方、 Kiro はAWSが提供するAI搭載の開発ツールで、 Kiro IDE と Kiro CLI の2つの製品で構成されています。 本PoCは、Q Devをターゲットとして開始しましたが、PoC期間中にKiroがGA(一般提供)され、Amazon Q Developer CLIがKiro CLIに改名されました(後方互換性あり)。本記事ではサービス全体をPoC開始時の名称に基づき「Q Dev」と表記しますが、CLIについては現行名称の「Kiro CLI」と表記します。 以下では、PoCで特に評価対象となったKiro CLIとKiro IDEの概要を説明します。 Kiro CLI Kiro CLIは、Amazon Q Developer CLIを引き継いだターミナルベースのAIエージェントツールで、ターミナルからAIと対話しながら開発・運用作業を行えます。主な機能は以下の通りです。 インタラクティブチャット :ターミナル上で自然言語を使い、コード生成やAWS操作を可能 MCP統合 :MCPサーバーを介して外部ツールと接続することで、AIの回答精度を向上させることが可能 カスタムエージェント :使用するツールや権限、コンテキストを定義した設定ファイルを用意することで、特定のワークフロー向けに特化したエージェントを構築・実行 Kiro IDE Kiro IDEは、VS Code互換のGUIベースの統合開発環境です。最大の特徴は 仕様駆動開発(Spec-driven Development) です。プロンプトから直接コードを生成する「バイブコーディング」とは異なり、以下の3段階のワークフローで構造的に開発を進めます。 Requirements(要件定義) :ユーザーストーリーをEARS記法 1 で形式化した requirements.md を生成 Design(設計) :アーキテクチャやデータフローを記述した design.md を生成 Tasks(タスク) :実装タスクを細分化した tasks.md を生成し、追跡可能な形で管理 KiroのGA前は、Q Devの契約でKiro IDEが正式にサポートされるかは不明瞭でした。しかしGA時に正式にサポートが発表されたため、PoCの評価対象に含めることとしました。 PoCの概要 背景 弊社では、全社的に生成AIの活用が推奨されています。全エンジニアを対象に1人あたり月額200米ドルの基準のもと、開発AIエージェントの導入が許可されています。 corp.zozo.com SRE部でもClaude Codeを利用できる環境でしたが、AWSとの親和性の観点からQ DevのPoCを実施することとしました。Kiro CLIにはデフォルトでAWSリソースと連携できる use_aws ツールなどの機能が組み込まれています。そのため、リソース管理やトラブルシューティングでより優れた体験を得られると考えました。 aws.amazon.com PoC体制 項目 内容 期間 2025年11月〜2026年1月までの3か月間 対象 SRE部37名 目的 Q DevがZOZOTOWNの運用業務の効率化に有用か判断すること 契約プラン Amazon Q Developer Pro PoCの進め方としては、初期設定およびハンズオンをAWS社のサポートのもと実施した後、各メンバーに自由に使ってもらい、フィードバックを収集する形をベースとしました。2週間に1度の定例で各チームの代表者から利用状況やフィードバックを共有してもらいつつ、必要に応じてKiroに関する共有や追加のハンズオンを実施しながら進めました。 Kiro IDEのハンズオンにおいては、以下のような記事を参考に、仕様駆動開発の流れを体験してもらう内容としました。 aws.amazon.com 利用状況の取得方法について Q Devではダッシュボードを有効にすることで全体の利用状況を把握できますが、ユーザーごとの利用状況は確認できません。 そこで、 ユーザーアクティビティレポート を有効にし、S3にCSVを出力して分析する方法を採用しました。 出力したCSVはAIツールに以下のようなプロンプトを渡して可視化しています。 S3バケット `{バケット名}` に保存されたAmazon Q Developerのユーザーアクティビティレポートをダウンロードして、この情報を可視化したHTMLファイルを以下の仕様で作成してください。 - IAM Identity Centerからユーザー情報を取得し、そのUserIDとcsvのUserIDをマッピングしてユーザー名を表示できるようにする - 1つのHTMLの中に全ユーザーの情報が含まれていてユーザーをボタンで切り替えることができるようにする - 土日は除外する - 3ヶ月分まとめたデータを1つのグラフで見れるようにする - Chat_MessageSentとChat_AICodeLinesにフォーカスしたグラフを作る。その際単位の違いを考慮して2軸とし、左軸がChat_MessageSent、右軸がChat_AICodeLinesとする フィードバックと分析 各チームから集めたフィードバック内容はカテゴリ別に整理して分析しました。大きく分けると、Kiro CLI・Kiro IDEの2つの観点となり、それぞれについていくつか紹介します。 Kiro CLIの評価 Kiro CLIの使用用途としては、主に以下のようなものが挙げられました。 CloudFormationなどのコード生成や修正 AWSに限らないコードの生成や修正 MCP経由でのAWSコスト確認 AWSリソースやEKSで発生したトラブルの原因調査 デフォルトモデルの違いによる体感品質の差 今回のPoCでは、自由に使ってもらったうえでフィードバックを収集する形式としており、モデルや設定の制限は行っていないため、厳密な精度検証ができる状態ではありません。 その前提のもとで、「バイブコーディングで生成したコードの品質がClaude Codeよりも低いと感じた」という意見がありました。これはデフォルトモデルの違いに起因すると考えられます。 項目 Kiro CLI Claude Code デフォルトモデル Auto(自動切り替え) Opus(最上位モデル固定)※Claude Maxプラン利用時 モデル選択の方針 コストパフォーマンスを重視し、タスクに応じて最適なモデルを自動選択 Claude Maxプランではデフォルトで最上位モデルを使用 特徴 コスト効率が高い 一貫して高い生成品質 このデフォルト設定の違いが、体感的な品質差につながった可能性があります。 AWS操作の利便性について 前述の通り、本PoCのきっかけの1つはAWSとの親和性の評価でした。比較しながら使っている中で、例えば以下のような違いが見られました。 ケース例 :EKS側の設定不備によってAWS FIS(Fault Injection Simulator)のアクション pod-cpu-stress が失敗した原因を調査する。 ツール プロンプト 結果 Kiro CLI FIS のアクションpod-cpu-stressが失敗する理由を調べて。 awsコマンドを実行し、実際のAWSリソースを調査した上で具体的な原因と修正手順を回答 Claude Code FIS のアクションpod-cpu-stressが失敗する理由を調べて。 Web Searchなどを活用して一般的な知識に基づき回答 この結果だけを見るとKiro CLIの方がAWSとの親和性が高いように見えますが、Claude Code側のプロンプトに以下のように1文付け加えるだけで同様の結果が得られました。 ツール プロンプト 結果 Claude Code FIS のアクションpod-cpu-stressが失敗する理由を調べて。 awsコマンドはインストール済みで、AWS_PROFILEも設定済みです。 awsコマンドを実行し、実際のAWSリソースを調査した上で具体的な原因と修正手順を回答 さらに、毎回プロンプトに付け加えなくても、MCPの設定や ~/.claude/CLAUDE.md に以下のように記載しておくだけで、同様にAWS操作を活用した回答が得られるようになります。 ## ツール - AWS CLI ( ` aws ` ) はインストール済み。AWSのトラブルシューティング時に積極的に使用してよい デフォルトの状態ではKiro CLIの方がAWS操作の利便性が高いと言えます。しかし、Claude Code側も簡単な設定次第で同等の操作が可能になるため、設定込みで比較すると大きな差は見られませんでした。 Kiro IDEの評価 Kiro IDEについては、 仕様駆動開発(Spec-driven Development) が使用できるという点が大きな評価ポイントとなりました。 仕様駆動開発ではエージェントやプロンプトを調整せずにRequirements → Design → Tasksを構造化されたドキュメントとして自動生成してくれるため、以下のような点が評価されました。 開発プロセスのトレーサビリティ :要件・設計・タスクが構造化されたドキュメントとして残るため、なぜその実装に至ったのかを後から追跡しやすい 属人化の抑制 :個人のプロンプト技術や暗黙知に依存せず、チームの誰が見ても開発の意図と経緯を理解できる 仕様駆動開発の活用事例 実際に仕様駆動開発が活用された例として、設計したアーキテクチャの妥当性を確認するための技術検証が挙げられます。この技術検証はインフラだけでなくアプリケーションの改修も含むもので、通常であればアプリケーションレイヤーとインフラレイヤーで担当者を分けて進めるものでした。 ここでKiro IDEのSpecモード(仕様駆動開発モード)が威力を発揮しました。アーキテクチャや設計方針が固まっている状態でそれをSpecに落とし込むことで、要件定義・設計・タスク分割が構造的に整理され、ゼロベースからの実装が非常に高速に進みました。結果として、通常は複数人で分担するような規模のPoCを1人で完遂でき、技術戦略の意思決定に必要な検証を迅速に行えました。 PoCの総括 PoCの結果、Kiro CLI自体は非常に便利なものの、既にClaude CodeなどのAIエージェントツールを利用している環境では、Kiro CLIだけでは追加導入の決め手としては弱いと感じました。 一方で、Kiro IDEの仕様駆動開発ワークフローは非常に好評でした。他ツールでも工夫次第で近い進め方は可能ですが、Spec→Design→Tasksが一貫して組み込まれた” デフォルト体験 ”として非常に高い価値があるという結論に至りました。 そのため、 必要なメンバーがKiro IDEを追加で利用できる環境を整備する という方針としました。 Amazon Q Developer ProとKiroのプラン選定について PoCはAmazon Q Developer Proで契約していましたが、本番導入にあたってはKiro Proを選定しました。両プランの比較は以下の通りです。 Amazon Q Developer Pro Kiro Pro 料金(月額) US$19/月 US$20/月 使用可能な機能 Kiro CLI・Kiro IDE Kiro CLI・Kiro IDE 使用量の単位 1,000リクエスト(推論呼び出し10,000回が1,000リクエスト相当) 1,000クレジット(1リクエスト≠1クレジット。消費クレジットはリクエスト内容により変動し、簡単なものなら1クレジット未満) 超過時の扱い リセットされるまで利用不可 上位プランへのアップグレードや従量課金の設定で利用を継続可能 ユーザーアクティビティレポートに含まれる情報 Kiro CLIでの使用量のみ Kiro CLI・Kiro IDE両方の使用量 使用量リセット 月次 月次 出典: Amazon Q Developerの料金プラン , Kiroの料金プラン (2026年3月5日現在) 料金差はKiro Proの方が月額1ドル高いですが、以下の2点を重視してKiro Proを選定しました。 クレジット枯渇時の柔軟性 :Amazon Q Developer Proではクレジットを使い切ると翌月のリセットまで利用できなくなる。一方、Kiro Proでは上位プランへのアップグレードや従量課金の設定により利用を継続できるため、業務が止まるリスクを回避できる。 利用状況の可視性 :Amazon Q Developer ProのユーザーアクティビティレポートにはKiro CLIでの使用量しか含まれず、Kiro IDEでの使用量を把握できない。Kiro ProではKiro CLI・Kiro IDE両方の使用量がレポートに含まれるため、チーム全体の利用傾向を正確に把握できる。 まとめ 本記事では、SRE部で実施したAmazon Q DeveloperのPoCの進め方と結果についてご紹介しました。 Amazon Q Developer/Kiroの導入を検討している方や、Kiroを使おうとしている方の参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com Easy Approach to Requirements Syntaxの略。「WHEN〜, THE SYSTEM SHALL〜」(「〜の場合、システムは〜しなければならない」)などの定型文で要件を自然言語で記述する手法。 ↩
こんにちは、技術戦略部CTOブロックの塩崎です。 当社ZOZOには1人あたり月額200ドルの基準のもと、Claude CodeやGemini CLIをはじめとした各種AI開発ツールを利用可能にする制度を2025年7月にスタートさせました。 corp.zozo.com 現在ではこの制度を用いて数百名という非常に多くの社員がClaude Codeを利用しています。このような中で組織全体のAI活用を推進するためには、それぞれの社員や部署のClaude Codeの利用状況をモニタリングすることが重要です。そのためにClaude CodeのOpenTelemetry機能を利用して、全社員のClaude Code利用状況を収集したので、本記事ではその手法を紹介します。 ccusageを使った利用情報の収集の課題 Claude CodeのOTel機能の紹介 作ったものの全体像紹介 利用情報を送信する部分 利用情報を受け取る部分 利用情報を分析する部分 利用情報の活用事例 まとめ ccusageを使った利用情報の収集の課題 Claude Codeの利用情報を収集する方法と言いますと、まずccusageを思い浮かべる人が多いかと思います。 ccusage.com 当社でも最初はこのccusageを利用しようとしましたが、課題に遭遇しました。まず利用者にccusageを実行してもらうという点が課題でした。ccusageはコマンド一発で利用状況を出力でき、プログラムから扱いやすい構造化されたJSON出力もサポートしています。そういう意味で非常に便利なツールではあるものの、数百名の社員から漏れなくccusageの出力結果を回収しようとすると手間がかかります。さらにこの作業は1回だけ実施すればOKというものではなく、継続的なモニタリングのためには都度ccusageを回収する必要もあります。 実際に全社員からccusageを集めるということを1回実施してみましたが、これを定期的に実施することは運用負荷が高いという結論になりました。数名から十数名の組織であれば定期的なccusageの収集が十分現実的に実施できるかもしれませんが、ZOZOの規模感では厳しい結果になりました。 Claude CodeのOTel機能の紹介 ccusageの代わりに注目した機能が、Claude CodeのOpenTelemetry出力機能です。 code.claude.com LLM APIのコールやユーザーのプロンプト入力などのイベントを設定したエンドポイントに対してOpenTelemetry仕様で送信する機能です。なお、入力したプロンプトは、プライバシーを考慮して文字数のみを取得して本文は取得していません。 この機能を用いてClaude Codeの利用情報を収集すれば、前述した課題が解決できると考えました。以降では収集するための仕組みを解説します。 作ったものの全体像紹介 まずは構築した仕組みの概要を紹介します。 Claude Codeから送信された利用情報はGoogle Cloudで動作しているCloud Runに送られ、最終的にBigQueryに格納されます。上の図からも分かるように利用情報を送信する部分・受け取る部分・分析する部分という3つのコンポーネントからなっているため、順番に解説していきます。 利用情報を送信する部分 まずは、利用情報を送信する部分を解説します。 各自の環境で動いているClaude CodeにOpenTelemetryの設定を入れています。全社員に対して設定を入れるように依頼をしたとしても、どうしても漏れが生じてしまうため、そのような依頼ベースの手法に頼らず、ファイルを配布することを考えます。ZOZOはMDMツールとしてIntuneを利用しているため、Intuneの仕組みを使って以下のパスにJSONファイルを配置しました。 Windows: C:\Program Files\ClaudeCode\managed-settings.json macOS: /Library/Application Support/ClaudeCode/managed-settings.json この場所に配置したJSON設定ファイルはManaged settingsと呼ばれ、優先順位が最も高い設定ファイルとして認識されます。 code.claude.com そのため、以下のような内容のファイルを配布し、全社員のClaude CodeにOpenTelemetryの設定を追加しています。基本的には公式ドキュメントの通りの設定なので詳細な解説は省略しますが、Resource Attributeだけは少々工夫をしました。AWS Bedrockをモデルプロバイダーとして利用している時に利用者のメールアドレスが取得できなかったため、Resource Attributeにメールアドレスを入れるような設定を追加しています。また、OpenTelemetry情報を受け取るサーバーに認証を設定しているため、そのための認証トークンも埋め込んでいます。 { " env ": { " CLAUDE_CODE_ENABLE_TELEMETRY ": " 1 ", " OTEL_METRICS_EXPORTER ": " otlp ", " OTEL_LOGS_EXPORTER ": " otlp ", " OTEL_EXPORTER_OTLP_PROTOCOL ": " http/protobuf ", " OTEL_EXPORTER_OTLP_ENDPOINT ": " https://<OpenTelemetry エンドポイント> ", " OTEL_EXPORTER_OTLP_HEADERS ": " Authorization=Bearer <認証トークン> ", " OTEL_RESOURCE_ATTRIBUTES ": " user.email=<会社メールアドレス> ", " OTEL_METRICS_INCLUDE_VERSION ": " true " } } 利用情報を受け取る部分 次にOpenTelemetry情報を受け取る部分を説明します。Cloud Runの周りのアーキテクチャ図をより詳細に書くとこのようになります。 図からGoogle Cloudをメインにした構成であることが分かります。ZOZOは分析基盤としてBigQueryを活用しており、最終的にBigQueryに情報を格納すると便利なため、Google Cloudをメインとしています。AWSやSnowflakeなどに分析基盤を持っている方は、それらの中にClaude Codeの利用情報も入れると既存のアセットをうまく活用できます。AWSの上で似たような仕組みを構築する場合は、以下のドキュメントなどが参考になるかと思います。 github.com (2026-03-16 追記) また、DatadogもOpenTelemetry情報を受け取ってダッシュボード化する機能を提供しているので、Datadogを導入している方はこちらも参考になるかと思います。 www.datadoghq.com (2026-03-16 追記ここまで) Claude Codeから送信されたOpenTelemetry情報はCloud Load Balancingで受け取ってからCloud Runに転送しています。Cloud Runで直接受け取る構成にもできますが、独自ドメインの対応やCloud Armorとの統合などを考慮してCloud Load Balancingを挟む構成にしています。この部分のTerraformのコードを以下に貼ります。 resource "google_dns_record_set" "otel_collector" { name = "<Domain of OTel Collector>" type = "A" ttl = 300 managed_zone = google_dns_managed_zone.coding_ai.name rrdatas = [ google_compute_global_address.otel_collector.address ] } resource "google_compute_global_address" "otel_collector" { name = "otel-collector-ip" } resource "google_compute_global_forwarding_rule" "otel_collector" { name = "otel-collector-forwarding-rule" target = google_compute_target_https_proxy.otel_collector.id port_range = "443" ip_address = google_compute_global_address.otel_collector.id load_balancing_scheme = "EXTERNAL_MANAGED" } resource "google_compute_managed_ssl_certificate" "otel_collector" { name = "otel-collector-cert" managed { domains = [ "<Domain of Otel Collector>" ] } } resource "google_compute_ssl_policy" "otel_collector" { name = "otel-collector-ssl-policy" profile = "MODERN" min_tls_version = "TLS_1_2" } resource "google_compute_target_https_proxy" "otel_collector" { name = "otel-collector-https-proxy" url_map = google_compute_url_map.otel_collector.id ssl_certificates = [ google_compute_managed_ssl_certificate.otel_collector.id ] ssl_policy = google_compute_ssl_policy.otel_collector.id } resource "google_compute_url_map" "otel_collector" { name = "otel-collector-url-map" default_service = google_compute_backend_service.otel_collector.id } resource "google_compute_backend_service" "otel_collector" { name = "otel-collector-backend" protocol = "HTTPS" load_balancing_scheme = "EXTERNAL_MANAGED" backend { group = google_compute_region_network_endpoint_group.otel_collector.id } log_config { enable = true sample_rate = 1 . 0 } } resource "google_compute_region_network_endpoint_group" "otel_collector" { name = "otel-collector-neg" region = "asia-northeast1" network_endpoint_type = "SERVERLESS" cloud_run { service = google_cloud_run_v2_service.otel_collector.name } } resource "google_artifact_registry_repository" "otel_collector" { location = "asia-northeast1" repository_id = "otel-collector" description = "OpenTelemetry Collector images" format = "DOCKER" } resource "google_secret_manager_secret" "otel_auth_token" { secret_id = "otel-collector-auth-token" replication { auto {} } } resource "google_secret_manager_secret_iam_member" "otel_collector_secret_accessor" { secret_id = google_secret_manager_secret.otel_auth_token.secret_id role = "roles/secretmanager.secretAccessor" member = "serviceAccount:$ { google_service_account.otel_collector.email } " } resource "google_cloud_run_v2_service" "otel_collector" { name = "otel-collector" location = "asia-northeast1" ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" invoker_iam_disabled = true template { scaling { min_instance_count = 1 max_instance_count = 10 } service_account = google_service_account.otel_collector.email containers { image = "$ { google_artifact_registry_repository.otel_collector.location } -docker.pkg.dev/$ { local.project_id } /$ { google_artifact_registry_repository.otel_collector.repository_id } /otel-collector:latest" ports { container_port = 4318 } resources { limits = { cpu = "1" memory = "1Gi" } } env { name = "GCP_PROJECT_ID" value = local.project_id } env { name = "OTEL_AUTH_TOKEN" value_source { secret_key_ref { secret = google_secret_manager_secret.otel_auth_token.secret_id version = "latest" } } } } timeout = "300s" } traffic { type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST" percent = 100 } lifecycle { ignore_changes = [ scaling ] } depends_on = [ google_secret_manager_secret_iam_member.otel_collector_secret_accessor ] } Cloud Runの中にはOSSのOpenTelemetry Collectorが動いています。 github.com 以下のような設定で動いており、受け取った情報をCloud LoggingとCloud Metricsに転送していることが分かります。 extensions : bearertokenauth : token : ${env:OTEL_AUTH_TOKEN} receivers : otlp : protocols : http : endpoint : 0.0.0.0:${env:PORT} auth : authenticator : bearertokenauth processors : batch : timeout : 10s send_batch_size : 1024 transform : error_mode : ignore log_statements : - context : log statements : - 'set(body, {"message": body}) where IsString(body)' - 'merge_maps(attributes, resource.attributes, "upsert")' - 'merge_maps(body, attributes, "upsert")' exporters : googlecloud : project : ${env:GCP_PROJECT_ID} metric : prefix : "custom.googleapis.com/claude_code" log : default_log_name : "claude-code-telemetry" service : extensions : [ bearertokenauth ] pipelines : metrics : receivers : [ otlp ] processors : [ batch ] exporters : [ googlecloud ] logs : receivers : [ otlp ] processors : [ batch, transform ] exporters : [ googlecloud ] traces : receivers : [ otlp ] processors : [ batch ] exporters : [ googlecloud ] telemetry : logs : level : info YAMLには基本的な設定しか書いていませんが、transformの部分がやや特殊なので解説をします。Claude Codeが送信するログに含まれるResource AttributeをそのままCloud Loggingに送信したところ、その情報がCloud Loggingに保存されませんでした。そのため、Resource Attributeの情報を全て抜き出してLog Attributeにコピーしています。 また、Cloud Loggingの標準的なログの保持期限は30日ですので、保持期限を伸ばしています。 _Default ログバケットの保持期限を伸ばすと影響範囲が大きいため、Claude Code用のログバケットを新規に作成し、そちらに流れるようにLog Routerを設定しています。該当箇所のTerraformコードを以下に示します。 resource "google_logging_project_bucket_config" "claude_code_logs" { project = local.project_id location = "global" bucket_id = "claude_code_logs" retention_days = 3650 enable_analytics = true } resource "google_logging_project_sink" "claude_code_logs" { project = local.project_id name = "claude-code-logs-sink" destination = "logging.googleapis.com/projects/$ { local.project_id } /locations/global/buckets/$ { google_logging_project_bucket_config.claude_code_logs.bucket_id } " filter = "logName=\"projects/$ { local.project_id } /logs/claude-code-telemetry\"" unique_writer_identity = true } 利用情報を分析する部分 最後はCloud Loggingに格納されているClaude Codeの利用情報をBigQueryから参照できるようにする部分を解説します。 ここ数年でCloud LoggingとBigQueryはかなり高度に統合されています。特に以下の機能を使うとCloud Loggingに保存されたデータに対して直接BigQueryからクエリを実行できます。Cloud Loggingの中身はBigQueryそのものかと思えるほど統合されています。 cloud.google.com そのため、Cloud Loggingに情報を入れることとBigQueryに情報を入れることはほぼ等しくなっています。以下のようにLinked Datasetを作成すれば2つの世界がシームレスにつながり、BigQueryからのクエリを実行できます。 resource "google_logging_linked_dataset" "claude_code_logs" { bucket = google_logging_project_bucket_config.claude_code_logs.id link_id = "claude_code_logs_bq_link" description = "Linked dataset for querying Claude Code logs from BigQuery" } Claude Codeの利用情報は以下のようにJSON形式で半構造化されたデータが json_payload フィールドに格納されています。 ここに対していちいちJSONパースをするのは手間なので、パース後のVIEWをイベントに応じて作成しています。 SELECT -- Standard attributes JSON_VALUE(json_payload, ' $."session.id" ' ) AS session_id, CAST (JSON_VALUE(json_payload, ' $."event.sequence" ' ) AS INT64) AS event_sequence, JSON_VALUE(json_payload, ' $."service.name" ' ) AS service_name, JSON_VALUE(json_payload, ' $."service.version" ' ) AS service_version, JSON_VALUE(json_payload, ' $."app.version" ' ) AS app_version, JSON_VALUE(json_payload, ' $."organization.id" ' ) AS organization_id, JSON_VALUE(json_payload, ' $."user.account_uuid" ' ) AS user_account_uuid, JSON_VALUE(json_payload, ' $."user.id" ' ) AS user_id, JSON_VALUE(json_payload, ' $."user.email" ' ) AS user_email, JSON_VALUE(json_payload, ' $."host.arch" ' ) AS host_arch, JSON_VALUE(json_payload, ' $."os.type" ' ) AS os_type, JSON_VALUE(json_payload, ' $."os.version" ' ) AS os_version, JSON_VALUE(json_payload, ' $."terminal.type" ' ) AS terminal_type, -- Attributes JSON_VALUE(json_payload, ' $."event.name" ' ) AS event_name, TIMESTAMP (JSON_VALUE(json_payload, ' $."event.timestamp" ' )) AS event_timestamp, JSON_VALUE(json_payload, ' $.model ' ) AS model, CAST (JSON_VALUE(json_payload, ' $.cost_usd ' ) AS FLOAT64) AS cost_usd, CAST (JSON_VALUE(json_payload, ' $.duration_ms ' ) AS INT64) AS duration_ms, CAST (JSON_VALUE(json_payload, ' $.input_tokens ' ) AS INT64) AS input_tokens, CAST (JSON_VALUE(json_payload, ' $.output_tokens ' ) AS INT64) AS output_tokens, CAST (JSON_VALUE(json_payload, ' $.cache_read_tokens ' ) AS INT64) AS cache_read_tokens, CAST (JSON_VALUE(json_payload, ' $.cache_creation_tokens ' ) AS INT64) AS cache_creation_tokens FROM <Cloud LoggingとLinkされたデータセット> WHERE JSON_VALUE(json_payload, ' $."event.name" ' ) = ' api_request ' ZOZOのBigQueryは以下の仕組みでkintoneの情報をリアルタイムで取得できるようにしてあります。そのため、kintoneに格納されている組織図情報などとも組み合わせて、どの組織がClaude Codeをよく利用しているのかを分析できます。 techblog.zozo.com 利用情報の活用事例 OpenTelemetry機能を使って収集した利用情報の活用事例を1つ紹介します。 Claude Codeを利用するための課金体系はいくつかあります。Pro / Max / Teamプランのような費用が固定されるものもあれば、Anthropic API / AWS Bedrockなどのような従量課金のものもあります。Claude Codeの利用量が少ない人には、前者の方法はコストパフォーマンスが悪いため、後者の従量課金制の仕組みに移行してもらっています。この移行のために、 api_request イベントの cost_usd フィールドを集計して、各自に最も適したプランをアナウンスしています。 SELECT DATE (event_timestamp, " Asia/Tokyo " ) AS DATE , user_email, SUM (cost_usd) AS cost_usd, COUNT (*) AS api_call_count, FROM <APIリクエストログのVIEW> GROUP BY ALL まとめ Claude Codeの利用状況をOpenTelemetryで収集する仕組みを紹介しました。組織のAI活用を推進するためにはClaude CodeなどのAIツールの利用状況を集計・分析することが肝心です。同じような課題に直面している人の助けになると嬉しいです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
.table-of-contents > li > ul { display: none; } はじめに こんにちは、データサイエンス部コーディネートサイエンスブロックの清水です。私たちのチームでは、WEARへ投稿されているコーディネート画像からVLM(Vision Language Model)で特徴を自動抽出するシステムを開発・運用しています。 プロンプト設計から推論パイプラインの構築、大規模推論まで、VLM・LLMを本番環境で活用する中、いくつかの運用課題に直面しました。本記事では、LLMOpsの全体像を整理した上で、観測基盤としてLangfuseを導入し、原因特定と改善の事例を紹介します。 目次 はじめに 目次 1. 直面した運用課題 モニタリングの不足 プロンプトとパラメーターの管理が分散 コスト管理の不透明さ 生成AIモデルのライフサイクルへの追従 2. LLMOpsの全体像とLangfuseの導入 LLMOpsとは Langfuseの選定理由 3. Langfuseの機能紹介 Tracing — モニタリングの不足を解決 Prompt Management — プロンプト管理の分散を解決 Cost Tracking — コスト管理の不透明さを解決 Tags・Session — モデルライフサイクルへの追従を支援 4. トレースによるエラー調査と改善事例 ダッシュボードによる問題の発見 ケース1:503エラー(APIの接続失敗) ケース2:Langfuseプロンプト取得のレイテンシー増加 ケース3:無限文字列の繰り返し出力 改善の全体的な効果 まとめ おわりに 1. 直面した運用課題 私たちは、小規模なデータを用いた実験や検証を経て、VLM・LLMの本番運用フェーズに移行しました。その中で、以下の4つの課題が浮かび上がりました。 モニタリングの不足 API呼び出し時のエラーや構造化出力のJSONパースエラーなど、想定されるエラーの監視が実行時のロギングのみに留まっていました。ログの粒度を細かく設定することで対処していましたが、推論対象のデータ数が増加するにつれ運用上の限界が顕在化し、生成AIの処理を体系的に記録・監視する仕組みの整備が求められていました。 プロンプトとパラメーターの管理が分散 運用中の特徴抽出プロンプトは10個を超えており、今後も増加が見込まれます。当時はプロンプトをExcel、パラメーター・configをGitHubで管理しており、バージョン管理が分散していました。プロンプト更新時にはGitHub側のパラメーター設定との整合性を都度確認する必要があり、一元的に管理する仕組みが整っていませんでした。 コスト管理の不透明さ APIの利用コストは請求画面上の合算値や日次の概算でしか把握できず、コスト急増時に原因となるリクエストや処理を特定することが困難でした。生成AIのモデルは世代ごとに料金体系が変動するため、日次推論の運用を見据えると、原因を追跡可能なコスト監視体制の構築が不可欠でした。 生成AIモデルのライフサイクルへの追従 生成AIモデルはライフサイクルが短く、迅速な更新サイクルへの追従が求められます。例えば私たちが利用しているGeminiでは、Stableモデルのリリースから概ね半年〜1年程度で提供終了を迎えるペースです 1 。モデル更新時には、データセットを用いた更新前後の精度比較やレイテンシーへの影響評価が不可欠です。 2. LLMOpsの全体像とLangfuseの導入 LLMOpsとは LLMOpsとは、大規模言語モデルの開発・運用・改善を体系的に管理するための一連のプラクティスです。従来のMLOpsがモデルの学習・デプロイ・監視を対象としているのに対し、LLMOpsではLLM特有の運用課題をカバーします。具体的には、プロンプトエンジニアリングやモデルの選択と更新、入出力のトラッキング、コスト管理などが含まれます。 IBM 2 、NVIDIA 3 、Databricks 4 、Dify 5 など各社のLLMOpsに関するドキュメントを調査しました。LLMOpsの全体像はDesign(設計)・Development(開発)・Operation(運用)の3フェーズに分類しました。特にDevelopmentフェーズではプロンプト管理や入出力のトレーシングと評価が重要です。Operationフェーズではエラー監視やコストトラッキングが中心的なプラクティスとして位置づけられています。 セクション1で挙げた4つの課題は、いずれもこのDevelopmentとOperationの領域に該当します。そこで、トレーシング・プロンプト管理・コスト監視を備えたLLMOpsツールを導入する方針としました。 Langfuseの選定理由 今回は観測基盤としてLangfuse 6 を採用しました。選定にあたってはLangSmith 7 やDify 8 を含む複数のツールを候補とし、以下の3軸で比較評価した結果、最も適していると判断しました。 セルフホスティングの可否 :社内のインフラ要件として、GCP上に自前でホスティングできることが重要でした。Langfuseはオープンソースで、この要件に最も合致しました。 既存の技術スタックとの統合のしやすさ :LangfuseはPython SDKを提供しています。私たちが利用しているVertex AI・LangChainなど主要フレームワークとの互換性もあり、既存のコードベースに自然に統合できました。 必要な機能の充足度 :Langfuseはトレーシング、プロンプト管理、コスト監視をワンストップで提供しており、マルチモーダル(画像入力のトレース)にも対応していることが決め手になりました。 3. Langfuseの機能紹介 ここからは、実際にLangfuseを導入した上で活用している主要な機能を、セクション1の課題との対応とあわせて紹介します 9 。 Tracing — モニタリングの不足を解決 Langfuseのトレーシングは、1回のリクエスト処理全体を Trace として記録し、その中の個々の処理ステップを Observation としてネストする階層構造をとります 10 。 上記の画像は、私たちの特徴抽出における実際のTrace画面です。左側のObservationツリーでは、1回の推論リクエスト全体が langfuse_gemini_request_with_retry というTraceとして記録されています。その配下に以下のObservationがネストされています。 fetch_langfuse_prompt (Span)— Langfuseからプロンプトを取得 append_feedback (Span)— フィードバック情報を付与 request_to_gemini (Generation, 8.36s, 4,986→302トークン、$0.000929)— Gemini APIの呼び出し validate_gemini_response (Span)— レスポンスの検証 parse_gemini_result (Span)— 結果のパース Observationには処理の期間を記録する Span と、LLM呼び出し特有の情報を記録する Generation の2種類があります。3番目の request_to_gemini がGenerationに該当し、実行時間・トークン数・コストといったLLM固有の情報が自動的に記録されます。右側のパネルでは入出力やメタデータも一覧表示され、1画面でリクエストの全容を把握できます。 従来のロギングでは個別のAPIコールしか追えず、エラー発生時にログを手動で突き合わせる必要がありました。Traceとして構造化することで、セクション1の「モニタリングの不足」を直接的に解決しました。導入も @observe デコレータを関数に付与するだけで済み、既存コードへの変更は最小限です。 Prompt Management — プロンプト管理の分散を解決 LangfuseのPrompt Management 11 は、プロンプトのバージョン管理・デプロイをLangfuse上で完結させる仕組みです。私たちが抱えていた「プロンプトはExcel、パラメーターはGitHub」という分散管理の課題に対して、以下の機能が直接的な解決策となりました。 バージョン管理とラベル 12 :プロンプトを更新するたびにバージョンが自動で作成され、変更履歴がイミュータブルに保持されます。各バージョンには production ・ staging などのラベルを付与でき、SDKからラベル指定で取得可能です。Diff表示機能もあり、バージョン間の差分をハイライトで確認できます。 Config 13 :プロンプトにモデル名・temperature・top_pなどのパラメーターを付与し、プロンプトと一緒にバージョン管理できます。コードの変更・再デプロイなしに、UI上でプロンプトとパラメーターをまとめて更新できるようになり、分散管理の解消に最も効いた機能です。 Traceとのリンク 14 :プロンプトをTraceに紐付けることで、どのバージョンがどの出力を生成したかを追跡できます。バージョンごとのレイテンシーやコストを比較でき、プロンプト改善の効果を定量的に測定可能です。 これにより、Excelとコードに分散していたプロンプトとパラメーターがLangfuse上に一元化されました。「どのバージョンが本番で動いているか」「何を変えたか」「変更の効果はどうか」を1つのツールで把握できます。 Cost Tracking — コスト管理の不透明さを解決 ダッシュボード 15 でモデルごとのコストやトークン数を時系列で可視化でき、運用時にコスト推移を一目で監視できます。セクション1で挙げた「コスト管理の不透明さ」について、従来は請求画面で合算値しか確認できませんでした。Langfuseの導入によりTrace単位・Generation単位で分解でき、異常なトークン消費の検知も容易になりました。 Tags・Session — モデルライフサイクルへの追従を支援 Langfuseでは、TraceにTags 16 やSession 17 といった属性を付与し、目的に応じてトレースデータを整理・フィルタリングできます。Tagsは任意の文字列をTraceやObservationに複数付与でき、アプリバージョン・LLM手法・実験IDなどの軸でUIやAPIからフィルタリング・グルーピングが可能です。Sessionは複数のTraceを1つのまとまりとしてグルーピングする仕組みで、 session_id を指定するだけで関連するTraceがセッション単位で集約されます。 私たちの運用では、評価実験やモデル更新のたびにTagsでTraceをグルーピングし、バージョン間の精度・レイテンシー・コストを比較しています。これにより、モデルのライフサイクルが短い環境でも、更新前後の品質を定量的に検証した上で移行でき、精度を担保した運用が可能になりました。 4. トレースによるエラー調査と改善事例 Langfuseを導入したことで、本番運用時に感じていた課題を解決できました。その中でも最も効果を実感したのはエラー調査と改善のフェーズです。ダッシュボードから問題を発見し、原因特定から改善まで行った実例を紹介します。 ダッシュボードによる問題の発見 日次での推論実行において、「早く実行が終わる日もあれば、非常に時間がかかる日もある」という現象が発生していました。実行ログからは、それぞれの推論対象となる入力データ数が大きく異なっていないことが事前に分かっていました。まずは原因を調査するためにLangfuseのダッシュボードを確認しました。 ダッシュボードで実行が完了したTrace数の推移を確認すると、最初は短時間で多くのAPIコールが成功するものの、その後に推論完了数が大幅に減少するパターンが確認できました。下図はその時に観測されたものです。 TraceやObservationを詳細に分析することで、以下の3つのケースを特定しました。 ケース1:503エラー(APIの接続失敗) 事象 :Geminiへの初回のAPIコールが503エラーで失敗し、その後に複数回の503エラーが起こった後にようやく成功するパターンが多発していました。 対策 :503エラーはAPI接続時のエラーであることから、API接続設定を調査しました。Vertex AIのPython SDKにはデフォルトで指数関数的バックオフ(Exponential Backoff)を利用したリトライ機構が備わっています 18 。私たちはこの仕組みを活かしつつも、システム全体が長時間ブロックされるのを防ぐため、リトライの上限回数(例:3回)やタイムアウト設定をクライアント側で適切にチューニングしました。結果として、一時的なエラーを許容しつつ、実行時のレイテンシー増加をコントロールできるようになりました。 ケース2:Langfuseプロンプト取得のレイテンシー増加 事象 :一部のTraceで、数時間〜最大10時間も処理がブロックされているケースが発生していました。Traceの実行時間を確認したところ、API呼び出しそのものではなく、Langfuseからのプロンプト取得処理のSpanに異常な時間がかかっていることが特定できました。 対策 :原因を調査した結果、プロンプト取得処理がリトライループの中に組み込まれていたことが判明しました。加えて、ネットワーク通信のタイムアウトが適切に設定されておらず、一時的な通信障害時に長時間プロセスがハングしていました。対策として、プロンプトの取得を最初の1回のみとし、オンメモリで保持するよう初期化処理を最適化しました。さらに、通信時のタイムアウト値を明示的に設定したことで、レイテンシーの異常な増加を根絶できました。 ケース3:無限文字列の繰り返し出力 Geminiの出力で特定の文字列が延々と繰り返され、構造化出力を想定していたJSONのパース処理で失敗してリトライが頻発しました。Trace Detail画面で出力内容がそのまま記録されていたため、無限に繰り返される文字列パターンを直接確認できました。あるTraceでは入力9,616トークンに対して出力64,999トークンという異常なトークン消費も記録されていました。 対策 :temperatureが0の場合、出力は決定的であるため、同じ入力に対してリトライしても同一の異常出力が再現されるだけで意味がありません。根本的な原因は特定の画像データとプロンプトの組み合わせにあると考えられます。しかし、膨大なコーディネート画像すべてのエッジケースを網羅する完璧なプロンプトの追求は困難です。そこで、エラー発生ごとにtemperatureを+0.1ずつインクリメントする実装を導入しました。temperatureを上げることで出力にランダム性が加わり、リトライ時に異なる出力が生成されるため、無限繰り返しから抜け出せる可能性が高まります。また max_tokens を明示的に指定し、万が一再発した場合でも異常な出力トークン数を制限できるようにしました。 改善の全体的な効果 それぞれ対策した結果、Traceのグラフも安定し、推論のスループットが一定で保たれるようになりました。Langfuse導入以前はVertex AIのログを手動で調査する必要があり、問題の全体像を把握するのに多大な時間を要していました。導入後は以下のような改善を実感しています。 エラー調査時間の短縮 :Trace単位で調査が完結するようになり、Trace一覧からエラーが起きていたAPI呼び出しが一目瞭然になった 入出力の精緻な監視 :各プロンプトの入力・出力・トークン数・コストを精緻に調査でき、異常検知が容易になった リトライ戦略の最適化 :リトライ回数や各リトライの出力がObservationとして記録され、定量的なデータに基づく改善が可能になった チーム内のコミュニケーション改善 :TraceのURLを共有するだけで、エンジニア間のエラー議論が具体的なデータに基づいて行えるようになった まとめ 本記事では、LLMの本番運用で直面した課題と、LangfuseによるLLMOps基盤の構築、トレースを活用したエラー調査と改善の事例を紹介しました。 Geminiのモデルライフサイクルに見られるように、Stableモデルのリリースから半年〜1年程度でRetirementを迎えるケースもあります。LLM特有の運用課題に対応するためには可観測性(Observability)の基盤を整えることが重要です。Langfuseは、トレーシング・プロンプト管理・コスト監視を統合的に提供するオープンソースのLLMOpsツールとして私たちの開発環境にフィットしました。特に、Traceの構造的な記録によってエラーの特定から対策実施までのサイクルを大幅に短縮できたことが最大の成果です。 今後は、Langfuseカスタムダッシュボードの活用、評価用データセットの構築とモデル更新時の自動評価パイプラインとの連携などに取り組んでいきます。さらなるLLMの安定した運用に活かしていきたいと考えております。 おわりに ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com Model versions and lifecycle ↩ LLMOpsとは ↩ LLM の手法をマスターする: LLMOps ↩ LLMOps ↩ What is Ops in LLMOps? ↩ Langfuse Documentation ↩ LangSmith Documentation ↩ Dify Documentation ↩ Why Langfuse? ↩ Tracing Overview - Langfuse ↩ Prompt Management Overview - Langfuse ↩ Prompt Versioning - Langfuse ↩ Prompt Config - Langfuse ↩ Link Prompts to Traces - Langfuse ↩ Model Usage & Cost Tracking - Langfuse ↩ Tags - Langfuse ↩ Sessions - Langfuse ↩ Vertex AI Generative AI inference API errors ↩
はじめに こんにちは、WEAR開発部 バックエンドブロックのaao4seyです。普段は WEAR というプロダクトのバックエンド開発を担当しています。WEARバックエンドシステムでは2025年夏頃からパフォーマンス課題が顕在化し、SLOの悪化や運用負荷の増大といった問題に直面しました。本記事ではこれらの課題に対し、チームとしてどのように改善サイクルを構築し継続的に取り組んできたかをご紹介します。 目次 はじめに 目次 WEARバックエンドシステムが抱えていたパフォーマンス課題 DB負荷上昇の要因 SLOへの影響 課題解決に向けたアプローチ 継続的な現状確認と課題の洗い出し SLO定例(バックエンドブロック全員 / 隔週) パフォーマンス定点観測(SRE + バックエンドブロック 各数名 / 隔週) 2つの定例の関係性 改善サイクルを加速する仕組み Database Monitoringの活用 パフォーマンス改善に特化したAgent Skills 取り組みの成果 定量的な改善 チームの意識変化 今後の展望 まとめ WEARバックエンドシステムが抱えていたパフォーマンス課題 WEARバックエンドシステムには大きく2種類のアクセスがあります。1つはWEARアプリやWebからのユーザーリクエスト(コーディネート検索など)です。もう1つは ZOZOTOWN や FAANS といった自社の他サービスや、自社EC連携企業などの外部システムからのAPIアクセスです。 2025年7月頃からRailsサーバのレスポンス悪化やAPIアクセスのエラー数増加が目立つようになり、監視アラートの発報頻度が増えてきました。また、定期バッチの失敗も以前より増える傾向にありました。 これらの問題に対処すべく調査したところ、リクエストを処理するDBのCPU使用率が徐々に上昇し始めていることがパフォーマンス悪化やエラーの原因の多くであることがわかりました。 DB負荷上昇の要因 調査した結果、シンプルにAPIリクエスト数が増加しつつあることがわかりました。WEARはtoCサービスに加え前述の通り自社の他サービスや外部システムへAPIを提供しています。ユーザーの行動によるリクエスト数の変化に加え、システム間連携のAPIのリクエスト数も増加していることがわかりました。また、この時期にリリースした機能にもパフォーマンスを悪化させる要因が含まれていそうであることもわかりました。 SLOへの影響 WEARではSLOを「最低限」と「理想」の2段階で設定し、7日・30日・90日の各期間でレイテンシを定期的に監視しています。DB負荷の上昇に伴い、最低限の目標値こそ達成できていたものの、理想値は明らかに悪化していました。 「最低限」と「理想」ともに、全リクエストの99%以上が目標レイテンシ内に収まることをしきい値として設定していますが、「理想」のSLOは7日間平均で80%前後まで落ち込むこともありました。 負荷が上がることでAPIのレスポンスタイムの悪化に加えて、バッチ処理の失敗といった悪い影響も出始めました。また、Sentryのアラートも増加する傾向にあり、対応に追われている状況でした。 これらからDBの負荷の軽減が急務となりました。 課題解決に向けたアプローチ 継続的な現状確認と課題の洗い出し パフォーマンス課題に継続的に取り組むために、現状を定期的に把握することが必要と感じ、まずはシステムの課題を抽出する時間を設けることにしました。2025年秋から2つの定例会を隔週で運営しています。 SLO定例(バックエンドブロック全員 / 隔週) SLO定例はバックエンドブロック全員が参加する場で、SLOの達成状況の共有と改善タスクの進捗確認・成果報告を目的としています。実はこの定例は以前から存在していたのですが、パフォーマンスが悪化し始めた時期の前後でさまざまな事情により開催が途絶えていました。状況の悪化を受けて再開した形です。 この定例には主に3つの役割があります。 役割 内容 チーム全体での課題感の共有 SLOダッシュボードを全員で確認し、どのAPIのレイテンシがどの程度悪化しているのかを目線合わせする 改善の知見共有 インデックス追加、クエリの書き換え、実行計画の制御など、各メンバーが取り組んだ改善の解法を発表しチーム内に知見を蓄積する Sentryエラーのトリアージ しきい値を超えたエラーについて対応方針を決め、担当者をアサインする 各回の事前準備として、担当者がDatadogのパフォーマンス定点観測ダッシュボードのスクリーンショットを取得し、Sentryのエラーを確認します。Sentryでは7日間で設定したしきい値以上発生しているエラーをピックアップし、GitHub Issuesに起票して優先的に対処する運用としています。 パフォーマンス定点観測(SRE + バックエンドブロック 各数名 / 隔週) パフォーマンス定点観測はSREチームとバックエンドブロックの合同で実施している定例です。SLO定例がチーム全体の状況共有に重きを置いているのに対し、こちらはDB周りの技術的な深掘りを行う場として機能しています。「DBのCPU負荷が高騰する前の2025年8月の状態に戻す」ことを目標に掲げています。 この定例には主に3つの役割があります。 役割 内容 DB周りのシステム状況の共有 SREがDatadog上のDB負荷やクエリパフォーマンスの直近の状況を共有する ストアドプロシージャ等の改善計画 DB上で動いている業務ロジックのパフォーマンス改善方針を議論し、バックエンドブロックでアサイン可能な状態にする クエリチューニングの相談 バックエンドブロック単独では解決困難なSQL Server特有の問題について、SREの知見を借りて解決策を検討する 各回では表に挙げた情報の共有に加え、具体的な改善方針を議論します。また、徐々に目先の課題だけでなく中長期的な方針について意見を出し合う場としても機能し始めています。 2つの定例の関係性 2つの定例は独立して運営しているわけではなく、相互に連携しています。 SLO定例はバックエンドブロックが主体となり、実際のコード変更を伴う改善を推進する場です。一方、パフォーマンス定点観測はSREと連携してシステムの詳細な状況を把握する場です。SLO定例で対処が難しい課題はパフォーマンス定点観測に持ち込み、SREの知見を借りて解決策を検討します。逆に、パフォーマンス定点観測で得られたシステム状況の知見はSLO定例にフィードバックされ、改善の優先度判断に活用されます。 例えば、クエリチューニングの方法として複数の選択肢がある場合、パフォーマンス定点観測で共有されたDBのリソース状況を踏まえて、どちらがより効果的かを判断できます。 改善サイクルを加速する仕組み 個々の改善をスピーディに進めるために以下のような仕組みを活用しています。 Database Monitoring の活用 パフォーマンス改善の起点となるのは「どのクエリが遅いのか」の特定です。WEARではDatadogの Database Monitoring (以下、DBM)を活用しています。DBMはSREチームが以前から導入してくれていたものですが、今回のパフォーマンス改善の取り組みをきっかけに、バックエンドブロックでもより積極的に利用するようになりました。 DBMを活用すると、遅いエンドポイントの発見から原因クエリの特定、実行計画の確認まで、ほとんどの場合Datadog上で完結します。具体的には以下の流れで調査を進められます。 APMで遅いエンドポイントを特定する そのエンドポイントから発行されているクエリの一覧をDBMで確認する 問題のクエリの実行計画をDBM上で直接確認する 特に有用なのは、実行計画の確認が容易な点です。DBMでは実行計画を常に取得できるわけではありませんが、取得できた場合にはインデックス追加やHINT句の付与といった改善の後、実行計画が想定通り変化したかをすぐに検証できます。SQL Serverは統計情報の更新タイミング次第で実行計画が不安定になることがあります。DBMで継続的に観測し、そうした変動も素早く検知できるようになりました。 パフォーマンス改善に特化したAgent Skills クエリチューニングの作業をさらに効率化するために、SQL Serverの実行計画の分析に特化したAgent Skillsを作成し、チーム内で共有しています。 WEARバックエンドブロックではAgent Skillsを共有するリポジトリを運用しています。その中にパフォーマンス改善向けのSkillsを追加しました。このSkillsは、実行計画のXMLやSentry IssueのURLを入力として受け取ります。MCP経由でSentryの情報も取得しながらタイムアウト箇所やボトルネックを特定し、インデックスの追加などの改善策を提案します。 SQL Serverの実行計画の読み解きには専門的な知識が求められます。Agent Skillsを活用することでチームメンバーの経験レベルに関わらず一定の品質で分析を進められるような環境作りに取り組んでいます。 取り組みの成果 まだ取り組みを始めたばかりであり道半ばではあるのですが、これらの取り組みを始めて徐々に成果が出始めています。 定量的な改善 一番根本的な課題となっていたDBのCPU使用率は、取り組みを始めてから少しずつ緩和される傾向にあります。リソースの使用率は外部環境にも依存するため、すべてが取り組みの成果とは言い切れません。しかし、少なくとも改善の兆しが見えつつある状況です。 また、SLOラベルが付与されたパフォーマンス改善PRの件数にも変化が現れています。定例再開前の2025年1月〜10月は月平均1.2件だったのに対し、再開後の2025年11月〜2026年2月は月平均6.0件と、約5倍に増加しました。 チームの意識変化 定量的な改善だけでなく、チーム全体の意識にも以下のような変化が出始めています。 早期検知 :定例でダッシュボードを定期的に確認する習慣が根付き、レイテンシ悪化やエラー増加に早い段階で気づけるようになった 影響把握の迅速化 :機能リリース後のパフォーマンス悪化を定例サイクルの中で早期に検知でき、原因特定から修正までのリードタイムが短縮された 知見の蓄積 :SLO定例での発表を通じてインデックス設計やHINT句の使い方、実行計画の読み方といった知見がチーム全体で共有されるようになった 今後の展望 現在の改善サイクルは順調に機能していますが、さらなる効率化に向けて以下のような取り組みも進めていきたいと考えています。 1つ目は、SentryやDatadogの通知を起点とした改善の自動化です。現在は定例で検知した課題をトリアージし、GitHub Issuesに起票しています。将来的にはこのプロセスを自動化し、エラーの検知から調査、改善PRの作成までをLLMで効率化したいと考えています。極力人手を介さず課題の解決にたどり着ける状態を目指します。 2つ目は、コンテキスト情報の自動収集です。クエリチューニングを行う際には、実行計画やテーブル定義、インデックス情報など多くのコンテキストが必要になります。これらの情報をLLMが自動で収集・整理できる環境を整備することで、改善の初動をさらに早めたいと考えています。例えば本記事内で紹介したDBM上のクエリの実行計画などにAI Agentが直接アクセスできるようにすることで、より素早く精度の良い結果を得られるのではと考えています。 まとめ 本記事では、WEARバックエンドシステムにおけるパフォーマンス課題と、その解決に向けたバックエンドブロックの取り組みを紹介しました。 パフォーマンス改善は一度やって終わりではなく、サービスの成長とともに継続的に取り組むべきテーマです。本記事が同様の課題に取り組むチームの参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com