イベント駆動設計を支える非同期処理について | お届けチーム取組紹介

前回記事で書いたように、お届けチームの扱うシステム領域ではさまざまな非同期処理が行われています。

product.10x.co.jp

この記事では

  • 非同期処理の採用するモチベーション
  • 非同期処理の実現方法

を書いています。

非同期処理の採用するモチベーション

主には次の2つのような目的がある箇所で非同期処理を行なっています。

  • 領域間をまたぐため
  • 同期的な処理をミニマルにするため

非同期処理を積極的に採用するにあたり、同期処理とのトレードオフや監視すべきメトリクス等、運用する際のポイントはいくつかありますが、これらは後続の記事で詳しく解説する予定です。

「領域間をまたぐ」

まず前提として「ピックパック」「お客様注文」「配達」のような領域を見出したとします。 その領域を「お客様の注文内容に応じてピックパックが指示される」「ピックパックで商品を品切れにしたら、お客様との取り引き中の商品を品切れにより、お届け数を減らす」というようなケースで「領域間をまたぐ」としています。

領域をまたぐ際にはサービスが目指す方向性と相談し、狙って領域同士を疎結合にするメリットがあれば非同期処理を採用しています。

とくにカスケード障害がさけられるメリットの大きい箇所は進んで非同期処理にしています。

領域間を非同期処理で繋ぐ

「同期処理をミニマルにしたい」

「同期処理をミニマルにしたい」ケースだと、

  • ピックした商品をパック予定のものへ追加する
  • 特定時間(便)におけるピッキングの進捗をサマリする

ようなケースがあります。

予定に追加では同期的にしても意味的には問題ないですが、ピックのAPIはとにかく素早くレスポンスを返したいので、クライアントアプリから呼び出されるRPCではピックのみ行い、パック予定への追加は非同期にしています。

RPCではパッキング用のデータは不要で、ピッキング用のデータは読み込み/書き込みで済みます。

  • 「ピックができたら必ずパック予定の追加できる」ような前提条件を意図的に作る。
  • 予定の追加が結果整合で問題ないかをUIや業務手順上確認する。

など、下準備があって非同期処理を実現できます。  おかげでピックのRPCは低いレイテンシを保てています。

左: 同期処理にすべて収める | 右: 非同期処理

95pertileのグラフで業務がある9時から16時に250ms程度で収まっているので優秀な方です

サマリというのは主にFirestoreでデータ集計をするには都度クエリを実行では実現が難しく、事前に集計しておきたいケースです。 イベント駆動による非同期処理導入前ではRPCをハンドリングしたプロセス上でバックグラウンド処理を実行していました。 サマリというだけあり、データ操作の競合も多いのとサーバが停止するとサマリされないケースもありました。 単一プロセス内での非同期処理からシステム単位での非同期処理になったことで確実に処理することを可能にしました。

ピックや品切れのイベントは三系統の集計の元になる

こういったイベント駆動な非同期処理はFirestoreとEventarc、Cloud Runの組み合わせで実現されています。

https://cloud.google.com/eventarc/docs/run/route-trigger-cloud-firestore?

実現するためのoverview

EventarcによってFirestoreの書き込みをトリガーをにして、ドキュメントを非同期メッセージとしてCloud Runに送りつけていますが、EventarcはCloud Pub/Subで成り立っています。 なので、監視やリトライ設定はCloud Pub/Subへの設定で行なっています。

またCloud Pub/SubのSubscriptionはPush Subscriptionで作られるので、Cloud RunへはプレーンなhttpリクエストとしてCloudEventsの形式にそって送られます。 簡単なメトリクス確認はCloud Run用のダッシュボードで賄えます。

簡単な図

一文で流れを書いてみると、「Firestoreに書き込んだドキュメントが、非同期メッセージとしてCloud Pub/SubにPushされ、CloudEventsの形式でCloud Runへhttpリクエストで送信されます」のようになります。

publish side

シンプルにFirestoreへ書き込みます。 非同期メッセージとして扱われるので基本的にCreateのみ起きるようにしています。 場合によってはDeleteをトリガーにしますが、非同期メッセージをイベントとして扱う際はCreateです。 また、非同期メッセージとして扱うドキュメントはイミュータブルなモノとして扱いためUpdateをしないようにしています。 イベントして扱う場合には発行時間を必ずペイロードに含める、イベントが起きた操作を追跡可能にする識別子を含めるなど一定型にはめて、イベントの発生を非同期メッセージで表せるようにしています。

subscriber side

http requestのbodyがCloudEvents の形式にそっていることを前提にしています。 subscriberにFirestoreのドキュメントを受けとることを前提にサーバを実装できる小さなライブラリを用意してます。 そのライブラリではpublishした際に付与する一意性のある識別子を利用してメッセージを重複して処理しないように制御したり、メッセージを順序正しく処理できるようなユーティリティも実装しています。 なので実装側はシンプルに処理したいイベントと処理そのものをペアでコードが書けるようになっています。

例外系など省略しているが、「何が起きたら」「何する」をイベントハンドラには書いている

メッセージによる非同期処理を本番導入するまでに

今回説明した非同期処理は機能実装の最初のデプロイ時から利用していた訳ではありません。 大雑把に以下3ステップで本番環境での実現しました。

  1. gRPCのリクエストハンドラ内で同期的にイベントハンドラを実行する
  2. gRPCのリクエストハンドラ内で非同期的にイベントハンドラを実行する
  3. gRPCのリクエストハンドラ内ではイベントの永続化だけし、別プロセスでイベントハンドラを実行する ← 今回紹介した仕組み

1. gRPCのリクエストハンドラ内でプログラム上、同期的にイベントハンドラを実行する

この段階ではこの記事で説明している「非同期を導入するメリット」を受けられないわけです。 とはいえリクエスト数が多くなかったり、リクエストのパターンによって問題なく動作します。 ここでのミソは将来非同期処理を導入していこうと画策することでイベントを処理するイベントハンドラで実装することです。

イベントハンドラと呼び出し側でイベント以外を共有しないことで、イベントハンドラの実行場所が変わっても同じロジックがそのまま動きます。

実際のコードを説明ように簡単にかつ色々カットしています

2. gRPCのリクエストハンドラ内でプログラム上、非同期でイベントハンドラを実行する

この段階は3. への準備段階です。 事前にイベント(メッセージ)の流量が本番同等で実行し続けられるか検証はしていましたが、3. の導入はどんなリスクがあるのか未知数でした。

そこでfeature flagを利用して2. と3. の切り替え(切り戻し)は即時でできるようにしました。 findy-tools.io

2, 3の並走している場合、二重処理が問題になるならイベントハンドラ側の仕組みで二重処理は抑制されます。

結果的に3の仕組みに問題がなかったので切り戻しをすることはありませんでした。

UseCaseから特定のイベントハンドラに直接依存しない実現方法を実際にはとっていますが記事用にシンプルにしています

3. gRPCのリクエストハンドラ内ではイベントの永続化だけし、別プロセスでイベントハンドラを実行する

この段階に来ると記事内で紹介しているようにFirestoreへの永続化をトリガーにEventarc経由でCloud Runによりイベントハンドラが実行されます。 2ではプログラム上非同期であるものの、システム的には同期的にイベントハンドラが実行されていました。 それが3ではシステム的にも非同期になります。 なのでイベントを発生させるロジックからは完全に後続の処理を気にすることはなくなります。

サンキューEventarc

メッセージによる非同期処理を実現するのために参考としているのはアウトボックスパターンです。

https://microservices.io/patterns/data/transactional-outbox.html

FirestoreがEventarcを利用することでドキュメントの書き込みをトリガーをするのが容易で非常に助かったところでした。 MessageRelay実現のためにクラウド側の仕組みのトリガーによってやり取りできるのはデータベースをポーリングするより運用しているうえで手間や気にすることが少なくありがたいです。 サンキューEventarc!

次回に続く

次回も引き続きお届けチームによるイベント駆動設計への取り組みを紹介していきます。

お届けチームでは絶賛エンジニアを募集中です。カジュアル面談もwelcomeです。 ご応募お待ちしております。

open.talentio.com

React Routerで始める新しい管理画面づくり

React Routerで始める新しい管理画面づくり

ソフトウェアエンジニアの金子(@naoty_k)です。

10Xでは、これまでネットスーパー事業者向けの管理画面(以下、管理画面v1)をNuxt.jsを使って開発・運用をおこなってきましたが、現在はReact Routerを用いた新しいアプリケーション(以下、管理画面v2)への移行を進めています。本記事では、なぜ移行することになったのか、どのように進めているのか、そして技術的な工夫とこれからの展望についてご紹介します。

移行の背景

これまで開発してきた管理画面v1はNuxt.jsをベースとしており、当時は迅速な開発に大きく貢献してくれていました。

しかし、数年の運用を経て将来的な拡張性やメンテナンス性に技術的な課題が生じ、フルスクラッチを検討していました。一方、10Xの他のプロダクトではReact + React Routerの構成が導入され始めていたため、新しい管理画面v2もReact Routerベースでフルスクラッチする判断に至りました。

これまでの歩み

管理画面v1の全画面を一気にv2に移行するのは現実的に不可能なため、画面単位での段階的な移行を計画しました。

まず、v1からv2への移行の技術的な制約や課題を明らかにするため、プロトタイプを実装し、認証方式の検証やgRPCサーバーとの通信などの課題を明らかにしていきました。Design Docを書き、SREチームやセキュリティチームなどを巻き込みながら技術的な意思決定を重ね、管理画面v2のアーキテクチャを設計していきました。

現在は共通して必要となる認証やバックエンドとの通信部分、レイアウトなどの実装を終え、比較的影響が少ない一部のCRUDを含む機能群をv2に移行しました。

認証における工夫

管理画面v2ではv1から引き続き認証基盤としてFirebase Authenticationを使っているのですが、v1とv2の間でログイン状態をシームレスに共有するための工夫が必要になりました。具体的には、Firebase Authentication SDKがIndexedDBに永続化した認証情報をService Workerで読み取りIDトークンを取得してv2へのリクエストヘッダーに付与するようにしました。

認証フロー(詳細は省略してあります)

バックエンドのgRPCサーバーは管理画面v1と同じものを引き続き利用しているため、フロントエンドでの工夫のみで認証状態を引き継げるようになりました。

モノレポ構成への移行

管理画面v2への移行にあたって、アプリケーション本体だけでなく共通で利用するコンポーネントライブラリやgRPCクライアントを含めたモノレポ構成に移行しました。

  • 管理画面アプリケーション本体
  • スキーマから自動生成されたgRPCクライアント用のパッケージ
  • 新しいデザインシステムを反映したコンポーネント用のパッケージ

といった複数のパッケージをpnpm workspaceを使い、1つのリポジトリで管理しています。これによって、依存関係の明確化や変更の一括管理が行えるように開発効率が改善しました。

なお、新しいデザインシステムについてはこちらの記事で紹介されています。

product.10x.co.jp

今後の展望

管理画面v2への移行は現在進行形で進んでいますが、画面の数も多く、単純な作業だけでもかなりのボリュームがあります。

そこで現在は、DevinなどのAIエージェントを活用して、コード資産をもとに画面の実装を自動化できないかという検証も始まっています。これが成功すれば、手作業の工数を減らしつつ、移行スピードを大きく上げられる可能性があります。

また、社内では月次でフロントエンド勉強会を開催するようになり、Reactや周辺技術などをテーマに情報共有や議論を行っています。技術的にキャッチアップしやすく、仲間と成長できる環境づくりも進めています。

おわりに

10Xでは、モダンなWebフロントエンド技術を活用してプロダクトの価値や開発体験を高めていく仲間を募集しています。ご興味のある方は、ぜひお気軽にご連絡ください!

open.talentio.com

検索の並び順改善を加速する道のり

10X のソフトウェアエンジニア @metalunk です。

このブログでは、10X が提供する小売チェーン向け EC プラットフォーム Stailer での検索改善について説明します。今回は特に “並び順” にフォーカスした内容です。

対象読者は主に検索エンジニアですが、「並び順改善の下準備が大事」の章以外は専門知識は出てこないため、検索以外を専門とするソフトウェアエンジニアのみなさんにも読んでいただけるはずです。

また、Stailer を使っている小売事業者の方も、使っていない小売事業者の方にも、ネットスーパーにおける検索機能はどう改善されているのか、なにが難しいのかをこの記事を通じて知ってもらえると嬉しいです。

本記事は8割 LLM が書きましたw みたいなものではなく、筆者が真心込めて手で書きました。

続きを読む

LLMを活用した商品検索タグ自動生成とRecall改善の取り組み(BigQuery × Gemini)

はじめに

こんにちは、10Xで検索推薦の機能・基盤の開発運用を担当している安達(id:kotaroooo0)です。 10Xでは小売チェーン向けECプラットフォームStailerにおいて、商品検索機能ではElasticsearchを利用しており、主にテキストマッチングによって検索を実現しています。

今回、LLMを活用して商品検索タグ(以下、タグ)を自動生成し、検索対象に加えることでRecallを改善しました。 本記事では、その取り組みの背景、具体的な施策、設計、評価、そして得られた成果についてご紹介します。

課題:情報不足による検索ヒット率の低さ

Stailerでは、商品データに含まれるテキスト情報が限定的であるために、ユーザーが検索しても意図する商品がヒットしないケースがありました。

例えば、ユーザーが「メイク落とし」と検索しても、商品名やカテゴリ名、商品説明文に「メイク落とし」という文言が含まれていないクレンジングオイルの商品はヒットしませんでした。 特に商品説明文自体が存在しない商品も多く、全体的に検索に利用できるテキスト情報が不足している状況でした。

この問題に対しては、小売事業者様が管理画面から手動で商品に検索キーワード(同義語や関連語)を紐付ける機能を提供していました。 しかし、対象となる商品数が膨大であることや、キーワード選定・入力の手間から、この機能は十分に活用されているとは言えず、手動運用によるカバーには限界がありました。

また、10X内部でもElasticsearchに類義語辞書を登録することで継続的にRecall向上に取り組んでいますが、こちらも手動でのメンテナンスが必要であり網羅性には限界がありました。

施策:LLMによる検索タグの自動生成と活用

これらの課題に対し、商品名とカテゴリ情報をインプットとして、LLMにタグを自動生成させるアプローチを採用しました。 生成したタグをElasticsearchの商品データに追加し、検索対象とすることで、ユーザーが入力するであろう多様な検索キーワードに対応し、Recallの向上を目指しました。

なぜタグ生成か?

ベクトル検索やシノニムの自動生成など他のアプローチも検討しましたが、まずは既存のテキスト検索の仕組みを大きく変更せず、低コストかつ迅速にRecallを改善する手段として、LLMによるタグ生成が有効と判断しました。

また、ネットスーパーの検索特性として、ユーザーは欲しい商品が比較的明確であり、商品名やカテゴリ名を表す短いキーワード(例: 「牛乳」, 「パン」, 「シャンプー」)での検索が多い傾向があります。 このような検索行動に対しては、関連キーワードをタグとして補強するアプローチが効果的ではないかと考えました。

設計

タグ生成からElasticsearchへの反映までの処理フローは、以下のようになっています。

タグ生成と紐付けの概要図

このフローは、大きく以下の2つの独立したプロセスで構成されています。

  • タグ生成パイプライン
    • 日次で実行
    • LLMを使ってJANコードごとにタグを生成し、その結果をFirestoreに保存する役割
  • Elasticsearchへのインデキシング
    • 差分更新であり在庫情報が更新されるタイミング(1日1回以上)で実行
    • 更新された在庫情報と、対応するJANコードのタグをマージし、Elasticsearchのインデックスを更新する役割

それぞれの実行タイミングが異なるため、生成されたタグが実際に検索結果に反映されるまでに最大1日のタイムラグが発生する構成となっています。

JANコード単位での生成と管理

Stailerは複数の小売事業者様がそれぞれ複数の店舗を運営しており、同じ商品(同一JANコード)でも在庫を持つ店舗ごとに個別のElasticsearchドキュメントが存在します。 全てのドキュメントに対してLLMでタグ生成を行うと、APIコストと処理時間が膨大になります。

そこで、JANコード単位でタグを生成・管理し、Elasticsearchへのインデックス時にJANコードをキーとして各ドキュメントにタグ情報を付与する方式を採用しました。 これにより、LLMのAPIコール数を商品数(JANコード数)に抑えることができました。

タグデータの更新について

更新頻度:バッチ更新 vs リアルタイム更新

No Option Pros Cons
1 バッチ更新 既存のMLパイプライン基盤を利用でき実装がシンプル 新商品が登録されてからタグが付与されるまでにタイムラグが発生する(最大1日)
2 リアルタイム更新 新商品登録後、即座にタグが付与される イベントドリブンな新しいアーキテクチャの構築・運用コストが必要

結論: バッチ更新を採用。タグは検索体験を補強するものであり、必須データではありません。 新商品へのタグ付与遅延による影響は限定的と判断し、実装・運用のシンプルさを優先しました。

更新方法:全件洗い替え vs 差分更新

No Option Pros Cons
1 全件洗い替え LLMモデルやプロンプト改善の恩恵を常に受けられる。常に最新の状態で上書きするため、差分管理ロジックが不要。 LLM APIコストの増大: 変更がない商品に対しても毎回APIコールが発生し、非効率。また、処理時間が長くなる。
2 差分更新 LLM APIコストの最適化: 新規商品や商品情報が更新されたもののみを対象にタグ生成を行う。また、処理時間も短くなる。 どの商品に対して再生成が必要かを判断する差分検出ロジックが必要になる

結論: 差分更新を採用。APIコスト、処理時間の抑制のため、差分検出ロジックの実装コストは許容できると判断しました。

タグ生成の品質とリスク

LLMが生成するタグの品質は100%正確ではありません。 不適切なタグが検索結果に悪影響を与えるリスクを考慮し、以下の設計にしました。

  • 手動でのタグ管理: 必要に応じて自動生成されたタグを確認し、追加・削除できる設計に。
  • フロントエンドへは非表示: 生成されたタグはあくまで検索ロジック内部で利用するものとし、商品詳細ページなどでユーザーに表示しない。
  • 検索スコアへの影響抑制: 検索結果のランキング精度を維持するため、タグの検索スコアへの重みは0に設定。これにより、タグによってヒットするようになった商品は検索結果の末尾に表示され、既存のランキングロジックへの影響を最小限に留めました。まずは「見つからない」を減らすことを最優先しました。

プロジェクトの進め方

1. PoC:タグ自動生成の実現可能性と品質検証

LLMによるタグ生成が実用に足るか、コスト・処理時間はどの程度かを見極めるため、以下の検証を行いました。

モデル、プロンプト

  • モデル候補: gemini-1.5-progemini-2.0-flash
  • 検証データ: 約1000件のJANコード(商品名、カテゴリ情報)
  • プロンプト: 商品名とカテゴリ名をインプットとし、複数のパターンを試行しました。
  • 評価:
    • 定性評価: 生成されたタグの関連性を目視で確認。
    • コスト試算: 各モデル・プロンプトでのトークン数を計測し、全商品(約15万JANコード)に適用した場合のAPIコストを試算。

今回のタスクは短いテキスト生成であり、gemini-2.0-flashで十分な品質のタグが生成でき、かつコストと処理速度の点で優位性があると判断し採用しました。 gemini-2.0-flashでは15万JANコードに対して、$6程度で想定以上に安価でした。

検証で十分な精度が期待できなければ別モデルも検証しようと思いましたが、gemini-2.0-flashがコストパフォーマンスが優れており今回は試しませんでした。

タグを生成する基盤

LLM APIを直接叩く方式も検討しましたが、既存のデータ処理基盤であるBigQueryとの親和性、運用負荷を考慮し、BigQuery MLのML.GENERATE_TEXT 関数の利用を第一候補としました。

cloud.google.com

  • 検証内容:
    • サンプルデータ(1000件)に対するSQLでのタグ生成処理の実行時間とコストを計測
    • 既存のMLパイプラインへの組み込みやすさを確認
    • 全データ(約15万JANコード)に適用した場合の処理時間を見積もり
      • 12時間程度と見積もれた。差分更新にするため、全データに対して生成するのは初回実行だけ。

結果として、BigQueryのML.GENERATE_TEXT は、既存のパイプラインへの統合も容易であると判断し、採用することにしました。

2. インデキシングの実装

タグ生成パイプライン(VertexAI Pipelinesで日次実行)

  1. 対象抽出: BigQueryで、前回の実行以降に新規追加または商品情報(商品名・カテゴリ)が更新されたJANコードを特定。
  2. タグ生成: 対象JANコードの商品名・カテゴリ情報を取得し、BigQueryのML.GENERATE_TEXTを用いてタグを生成。
  3. 結果保存: 生成されたタグを、JANコードをキーとしてFirestoreに保存。

indexing worker

  1. データ取得: Pub/Subから更新対象の在庫情報(JANコード含む)を取得。
  2. タグ取得: 対象JANコードをキーとしてFirestoreから対応するタグ情報を取得。
  3. データマージ: 在庫情報とタグ情報をマージし、Elasticsearchに投入するドキュメントを生成。
  4. インデキシング: Elasticsearchにデータをインデックス。

3. 商品検索ロジックの評価とプロンプトチューニング

生成されたタグを検索対象に含めた新しい検索ロジックを評価し、問題があればプロンプトやロジックを修正するサイクルを回しました。

評価

タグ追加によるRecall向上(ヒット件数増加、ゼロマッチ率減少)を期待し、評価を行いました。

  • 定性評価

    • 目的: 新ロジックで不適切な商品が検索結果に含まれていないか、意図通りに検索結果が改善されているかを確認。
    • 方法: デモ環境で、実際に様々なキーワードで検索し、新旧ロジックの結果を目視比較。
    • 主な確認キーワード:
      • 従来ゼロマッチだったキーワード
      • 検索結果数が少なかったキーワード
      • 検索ボリュームが多い主要なキーワード
  • 定量評価

    • ゼロマッチ改善率: 従来ゼロマッチだったキーワード群を新ロジックで再検索し、ヒット件数が0件でなくなる割合を計測。
    • ヒット件数変化: 主要キーワード群における平均ヒット件数の変化を計測。
    • nDCG: 既存の検索ログを用いて算出したnDCGスコアが、新ロジック導入によって悪化しないことを確認。タグによるヒットは結果の末尾に追加されるため、理論上nDCGへの悪影響はないと考えられたがガードレールとして確認。

プロンプトチューニング

デモをしていると、タグの品質にばらつきが見られ、いくつかの具体的な課題が見つかりました。 例えば、冷凍でない「うどん」に対して「冷凍うどん」という誤った温度帯を示すタグが付与されたり、商品の実態とは異なるサイズ(例: 「大容量」)やブランド名が生成されたりするケースがありました。

また、商品の「味・成分」(例: 「無添加」「グルテンフリー」)や「用途・対象」(例: 「赤ちゃん用」)に関するタグが誤って生成されることはリスクが高いと判断しました。 これらの情報が不正確だと、ユーザーに誤解を与え、購買体験や安全性を損なう恐れがあるためです。

このリスクを低減し、より安全で関連性の高いタグを生成するため、プロンプトに具体的な制約ルールを盛り込みました。 試行錯誤を経て、最終的に以下のプロンプトを作成しました。

Instacartのようなネットスーパーの商品検索精度改善を担当しています。
商品検索のRecallを向上させるために、次の商品に関連するキーワードを生成してください。
【商品情報】
商品名: {product_name}, カテゴリ名: {category_names},
【キーワード生成ルール】
1. 商品名やカテゴリ名に含まれる単語、または部分一致する単語は生成しない。
2. 次のような誤解を招く可能性のある属性を示すキーワードは生成しない。
- 温度帯: 冷凍, 冷蔵, チルド, 常温
- 味・成分: 無糖, 加糖, 低糖質, グルテンフリー, オーガニック, 無添加
- サイズ・量: 大容量, 業務用, 小分け, 一人前, ファミリーサイズ
- ブランド・地域: 北海道産, 沖縄産, 国産, 地域限定, 直輸入
- 用途・対象: 赤ちゃん用, 幼児向け, ペット用
3. 商品に関連性が高く、かつ一般的に検索される可能性のある単語を優先する。
4. 料理・用途・関連カテゴリを考慮したキーワードを生成する。
【出力ルール】
- 出力は生成されたキーワードのみで構成し、スペースなしのカンマ区切りで出力する。
- 生成するキーワードの数は、関連性が高い順に最大3つに制限する。

4. 検索ロジックの修正と本番リリース

評価とチューニングを経て、品質に問題がないと判断できた段階で、生成されたタグを検索対象フィールドに加えるロジック修正を行い、本番環境へリリースしました。

5. 本番リリース後の効果測定

今回は、開発期間やリソースの制約からABテストやインターリービングは実施せず、リリース前後の主要KPIを比較することで効果を測定しました。

結果

  • ゼロマッチレート: 15%減少
  • 平均ヒット件数 : 8%増加

ゼロマッチレートのモニタリング

おわりに

本記事では、商品情報の不足による検索Recallの低さという課題に対し、LLMを活用して検索タグを自動生成し、既存のElasticsearchベースの検索システムに組み込むことで改善を図った事例を紹介しました。 設計段階でのトレードオフの考慮や、LLM生成物の品質担保、評価方法などが、同様の課題に取り組む方々の参考になれば幸いです。

今回はLLM活用の第一歩として、まずは既存の仕組みと連携しながら検索のヒット率を改善しましたが、LLMにはお客様の買い物体験を大きく進化させる可能性があると考えています。

例えば、ネットスーパー特有の課題として、一度の買い物で多くの商品をカートに入れる手間が挙げられます。 RAGのような技術を取り入れれば、「今週の献立に必要な食材をまとめて提案し、ワンタップでカートに追加できる」といった、新しい買い物体験を提供できる可能性があります。 このようなユーザーの課題解決に繋がるLLMの活用についても、今後挑戦していきたいです。

技術的チャレンジへつながるピックパックのモジュール化 | お届けチーム取組紹介

はじめに

10X ソフトウェアエンジニアの鈴木です。 これからしばらく「お届けチーム取組紹介」と題して「イベント駆動アーキテクチャ」につながることを複数の記事に渡ってお伝えしていきます。 product.10x.co.jp

お届けチームでは、 「ピックパック」というお客様から承った内容を元に、商品を売り場や在庫置き場からとってきて(ピック)、箱詰め(パック)する業務領域(以下ピックパック)のシステムを疎結合化することで、技術的な課題を解決することを目指してきました。

社内では「モジュール化」という言葉を使用して取り組んできましたが、これは「ピックパック領域のコードをお客様へ対面するためのコードと疎結合にする取り組み」でした。本記事では、この取り組みの概要を紹介します。技術的な詳細については、後続の記事で解説します。

注文内容をピックしてパックして、配達へ受け渡す

チャレンジ前の構造

取り組み前のシステムでは、Orderクラス(as 1 Firestoreドキュメント)に、注文に関するあらゆる領域のロジックとデータ永続化処理が集中していました。具体的には、お客様の注文内容、ポイント処理、クーポン処理などのお客様との契約に関する情報から、ピックパック業務の状態、配達の状態など、注文処理全体に関わる情報が含まれていました。

このような設計は、単一のプロダクトで注文受付から配達完了までを管理できるという利点がある一方で、システムが密結合になり、機能の拡張や修正が困難になるという課題がありました。

たくさんの領域の関心ごとがOrderにいる図

主な課題は以下の3点です。

  • コード変更の複雑さ: 複数のチームがOrderクラスを共有していたため、コードを変更する際に他のチームとの調整が必要となり、開発のボトルネックとなっていました。
  • データ操作のロック競合: Orderドキュメントが複数の商品情報を含んでいたため、別々の商品に対する同時操作が頻繁に競合し、データ整合性の問題が発生していました。とくにピッキングの業務中では個別商品ごとに作業は並走するので問題が顕著に現れていました。
  • カスケード障害のリスク: ピックパック領域の障害が、お客様の注文明細表示など、直接関係のない機能にまで影響を及ぼすカスケード障害が発生しやすい状況でした。 例えば、ピックパック処理に問題が発生した場合、お客様が注文内容を確認できなくなるという問題が発生していました。お客様は、注文した商品が届くかどうかを知りたいだけなのに、スタッフの業務プロセスが原因で注文内容すら確認できなくなるという事態は避けるべきです。

とくにお届けチームのプロダクト改善はピックパックに関わることが現在多く、コードや構造改善の恩恵が得やすいということで改善の実施に踏み出しました。

構造の改善

これらの課題を解決するために、以下の取り組みを実施しました。

  • データモデルの分離: お客様対応に必要なデータとピックパック業務に必要なデータを、別々のFirestoreドキュメントに分割しました。これにより、各領域のデータを独立して管理できるようになりました。
  • コードの分離: データ操作を行うクラスも、お客様対応とピックパック業務で分離しました。これにより、各領域のコードの独立性が高まり、変更の影響範囲を局所化できるようになりました。

これらの取り組みに伴い、以下の2つのデータ連携が必要になりました。

  • お客様からの注文内容に基づいて、ピックパック担当者に指示を出す。
  • ピックパックの進捗状況をOrderドキュメントに反映する。

これらのデータ連携を、業務上の意味を持つ「イベント」としてモデル化し、イベント駆動型の非同期処理コンポーネントを導入することで実現しました。 また、ピックパックの進捗状況をリアルタイムに集計するために、イベントをトリガーとして集計処理を実行する仕組みも導入しました。

領域が非同期処理によって連携する(非常にシンプルにした図)

非同期処理のトリガーになるイベントを元に集計が起きている

これらの改善により、Orderとピックパックの間の影響関係がインターフェースを通じて明確になり、「実はXXXにも影響があった」という予期せぬ影響を減らすことができました。

ロック競合についてはピックの操作はPickingというある1注文の1商品ごとにあるデータへの操作になったので、最終的にOrderドキュメントへのデータ連携処理が残るものの、バックグラウンドで非同期に処理されるためスタッフの業務への影響はほとんどなくなりました。

カスケード障害のリスクも、データとコードの分離、および非同期処理の導入によって大幅に低減しました。ピックパック処理にバグがあった場合でも、ピックパック業務が開始されるまでの間に修正できれば、お客様やスタッフに影響が出るのを防ぐことができるようになりました。これは、改善前はピックパック処理のバグが注文受付自体を妨げる可能性があったことと比較すると、大きな改善です。


本記事では、ピックパック領域の疎結合化に関する取り組みの概要と、その効果について説明しました。技術的な詳細については、後続の記事で詳しく解説する予定です。

読者になるボタンを押すと後続の記事が公開されたときに通知を受けとれるのでおすすめです。

アプリを起動せずにアプリを開発して品質と生産性を上げる

先日、Flutter Tokyo #6 で同タイトルの発表をさせてもらいました。10分ほどの発表でしたが、割と良い反応をいただけたので、少し内容を補足してブログとしても公開します。

発表時のスライドは以下です。


前提

一般的に、モバイルアプリは自動テストしづらい箇所が多いと言われます。たしかに、画面から素朴に実装していくと、自動テストでは確認が難しくなりやすいです。そうなってしまうと、アプリを起動して手動で動作確認するしかなくなってしまいます。

一方で、設計やツールを適切に使えば、モバイルアプリであっても広範囲が自動テストで検証可能になります。手動での動作確認を完全になくすことはできませんが、手動テストへの依存度は下げられます。

手動テストへの依存度が下がると、検証時間の短縮、手戻りの抑制など、さまざまなメリットが得られます。また、手動テストにも良い影響を与え、手動テストでなければ確認できないことに集中できるようになります。

なぜテストしづらくなるのか

テストしづらさは、以下の2つに分解できます。

  • テストする条件の再現が難しい。
  • 結果の検証が難しい。

原因は色々と考えられますが、最もよくある原因はテスト対象の役割が大きすぎることだと思います。モバイルアプリではデータとUIの両方を制御する必要がありますが、両者の役割が混在すると状況はより厄介になります。また、役割が大きくなると記述するテストケースも網羅しづらくなり、抜け漏れが出てテストの価値が下がりやすくなります。

役割を混ぜないこと(特にデータの制御とUIの制御)、役割を大きくしないことを守れば、あとは場所ごとに適切な手段を使えば十分テスト可能となります。

例を使って解説

例としてGitHub APIでリポジトリ検索をして、結果を表示する画面を考えます。

この画面はそれほど複雑ではありませんが、それでも一気にすべてをテストするのは得策ではありません。役割の種類を混ぜないこと、役割を大きくしすぎないことを守りながらクラスの境界を設計し、それぞれをテストしていきます。

データとUIの関係を疎にする

数ある責務分割の中でもトップクラスで効果的なのは、データとUIの関係を疎にすることです。これを守ると、先に挙げた「テストしづらさ」の要因の1つである「テストする条件の再現が難しい」は解消しやすくなります。

依存関係の向きは常にUI → データなので、データとUIの関係を疎にするには、データ側のロジックの詳細がUI側に漏れないように設計します。今回の例ではデータ側はGitHubと通信を行い、レスポンスのステータスコードを確認し、JSONをパースし、データが想定通りか検証する、といった処理が必要ですが、この一連の内部処理の制御にはUIは関わらないようにします。

具体的な実装としては、データ側は以下のようなインターフェースを提供します。

UI側の視点で見れば、データ側に入力を渡せば結果が得られる状態になっています。一連の内部処理について立ち入ることはできず、結果だけを受け取るようになっています。

出力を適切にレイアウトに反映するには、内部処理の中で何が起きたか知る必要がある場合もあります。そういった場合に備えて、データ側では実行結果に適切な情報を含めるようにします。上記の例では、検索の失敗時にGitHubExceptionをスローすることになっていますが、この例外に適切な情報が含まれていれば、UI側は利用者と適切にコミュニケーションができます。

データとUIの関係が疎になると、UIの役割は「データに入力を渡して、出力をレイアウトに反映するだけ」というシンプルなものになります。これはコード上にもあらわれ、実際にwidgetの実装は以下のようになります。

なお、ここではRiverpodを使っており、前掲のデータ側のインターフェースを呼び出すproviderを以下のように定義しています。

ここでもう1つ重要なのは、UIが依存するデータ側の実装が差し替え可能になっているということです。Riverpodにはproviderを差し替えるoverrideという機能があり、データ側の出力を任意のものに変えられます。これによって、先に挙げた「テストしづらさ」の要因の1つである「テストする条件の再現が難しい」は解消されるという訳です。

役割がシンプルになったUIをテストする

UIの役割は「データに入力を渡して、出力をレイアウトに反映するだけ」というシンプルなものになりました。テストでもこの役割を検証します。

「テストしづらさ」の要因として「テストする条件の再現が難しい」「結果の検証が難しい」の2つを挙げていましたが、これまで説明していなかった「結果の検証が難しい」は、UIのテストにおいてはgolden testを使って解消します。

実際のテストケースに入る前に、テストする条件が再現されるwidgetを返す関数を準備します。テストする条件のデータの出力を渡して、providerをorverrideしてwidgetを返します。

ここまで来れば、あとは条件ごとにテストケースを書くだけです。以下のテストコードは、データ側がリポジトリのリストを正常に返した時に、その結果がUIに正しく反映されるか検証しています。

左側の画像はテストに使用するgolden fileです。右側のテストコードを書いて、flutter test--update-goldensオプションをつけて実行すれば生成されます。実装中はgolden fileが想定通りになるまで修正と再生成を繰り返します。一度完了したら、以降はテストのリファレンスとして使用します。

通信が失敗した場合のテストケースも同様に書けます。今度はresultFuture.error(GitHubException.connectionFailed()を渡して、通信が失敗した場合を再現しています。

このように「テストしづらさ」の「テストする条件の再現が難しい」と「結果の検証が難しい」をそれぞれ解決すれば、UIのテストも簡単に書くことができます。このような形で開発を進めていけば、アプリを起動して動作確認する必要性は自然と下がっていきます。

品質と生産性

視点を品質と生産性に移すと、以下のことが言えます。

  • 手動テストでは再現が難しい条件も含めて、あらゆる条件を簡単に再現できる。
  • 自動テストがリファクタリング耐性を持っているので、変更がしやすくなる。
  • 高速に反復実行できるため、開発中の修正サイクルを速く回せる。
  • 頻繁な全件実行が現実的なので、マイナーケースのデグレも早く発見できる。

あらゆる条件を簡単に再現できる

手動テストでは再現が難しい条件も、今回の形なら簡単に再現できます。

例えば、GitHubで障害が発生して500エラーを返した時にUIがどうなるかは、手動では再現が難しいです。しかし、今回の形であれば_createWidget()resultGitHubException.unsuccessfulResponse(statusCode: 500)を渡せばそれが再現できます。

あらゆる条件の再現が簡単になり、自動テストが実装されるということは、実行コストが大幅に圧縮され、その条件の検証が行われる機会が増えるということになります。それが、最終的には品質の底上げに繋がります。

リファクタリング耐性があるので変更がしやすい

UIのgolden testは、描画結果のスクリーンショットを使って検証しているため、widgetの内部実装には依存していません。そのため、widgetの内部実装を大幅に変えたとしても、テストコードを変える必要はなく、リファクタリング耐性があると言えます。

検証内容を変えない限りはテストコードを固定できるため、その分内部実装は柔軟に変更しやすくなり、結果として変更を早く終えられるようになります。

高速な反復実行による開発中の修正サイクルの高速化

今回のテストはFlutterのwidget testであり、これは非常に高速です。実装の仕方にもよりますが、今回のような形であれば10件くらいのテストケースも実行は1秒ほどで終わります。

また、Flutterにはhot reloadがありますが、これと比べてもメリットがあります。今回の形では、再現が難しい条件や、テキスト入力などのUIの操作が必要な条件などであっても、確認と修正のサイクルの速度はあまり影響を受けません。

頻繁な全件実行による品質の底上げ

テストの実行速度が速いため、手元でもCI上でも全件の実行が現実的になります。これにより、アプリの大通りでないケースについても、常に検証を行うことができます。

最終的に手動テストが不可欠であることに変わりはありません。なぜなら、今回の形のテストは結合した状態でテストしている訳ではないため、結合に起因した不具合や、使い勝手という観点の検証ができないためです。しかし、それでも自動テストは手動テストの負担を下げたり、稀にしかテストされないマイナーケースも含めた全体の品質の底上げに役立ちます。

例えば、レイアウト崩れを早期発見するgolden testがあれば、レイアウト崩れによる手戻りの頻度は大幅に抑えられ、開発担当者とテスト担当者が手戻り対応に割く時間を抑えることができます。

UI以外のテスト

今回はUIのテストを中心に説明しましたが、アプリを起動せずに開発を進めるにはデータ側のテストも同様に重要です。また、UIに関しても画面遷移のテストや、分析ログのテストなど、まだまだテストすべきものがあります。

これらについては、また別の機会に解説したいと思います。

まとめ

  • モバイルアプリ開発は手動テストに依存した部分が増えやすいと言われる。
  • しかし、役割をちゃんと分解すれば、ほとんどのことが自動テストできる。
  • それなりに頑張る必要はあるが、やる価値はある。

メンバー募集

10Xではソフトウェアエンジニアを募集しています(特にバックエンド)。

今回はモバイルアプリのテストの話を書きましたが、役割の分解のところはバックエンドでのテストにも通ずるものです。こういった考え方で開発をより上手くやっていきたいという方に是非来ていただきたいです。

興味があれば、ぜひ会社紹介をご覧ください!

open.talentio.com

お届けチームがイベント駆動アーキテクチャを採用した理由

ネットスーパーで注文された商品が効率よく確実にお客さまのもとに届くためには、店舗でのピッキングやパッキング、配送といった業務が必要となります。この業務を支えるStailer上のアプリケーション開発を担っているのが、お届けチームです。

10x.co.jp

お届けチームは昨年「イベント駆動アーキテクチャ」を導入する取り組みを行いました。イベント駆動アーキテクチャをどんな狙いで導入し、どんな成果が得られたのか。お届けチームの開発メンバーである鈴木さんに聞いてみました。

イベント駆動アーキテクチャ: イベント駆動アーキテクチャは、システム内で発生するイベントをトリガーにして処理を実行する設計パターン。コンポーネント同士が直接やり取りをせず、イベントを介して情報を共有するため、疎結合になり、スケーラビリティと柔軟性が高くなる。イベントはイベントバスやメッセージキューを通じて通知され、受け取ったコンポーネントが必要に応じて処理を行う。

密結合なシステムの課題

── まず最初に、どんな課題を解決するためにイベント駆動アーキテクチャを導入しようとしたのか、というところを聞いてみていいですか?

鈴木: はい。 元々あった課題として、Stailerはいろんな領域のデータだったり、コードだったりと、いろんなものが密結合になってたんですね。 密結合になってるがゆえに、ソースコードを触るときに、あっちにお伺いを立てて、こっちにお伺いを立てて、みたいな一つ触るのにも気にすることが多いという状況でした。そうした、開発の面での認知負荷が高いというところが1つ。

それに、障害リスクの観点がもう1つ課題としてありました。ネットスーパーをやってると、エンドユーザーが利用するECサイトと、店舗のスタッフの方が利用するシステム、2つのシステムを管理する必要があります。店舗向けのシステムで障害起きてしまうと、ECサイトまで注文できなくなるようなシステムが本当にいいシステムなのか?というと、そうではないと思うんですね。

実際、密結合がゆえにスタッフ向けのシステムの面がちょっと壊れたりすると、エンドユーザー向けのECサイトでも注文が受け付けられないといったような障害に繋がる、カスケード障害のリスクが当時はいろんなところに点在していました。そういうのを解決したくてイベント駆動を採用しようとしてた。 ってとこですかね。

Eventarcの活用

── そうしたモチベーションでイベント駆動を導入し始めたと思うんですが、導入するにあたってどんな部分が大変でしたか?

鈴木: そうですね。やはり、スタート地点でコードやデータがすごい密結合になっていたので、これをどうやって分解しようかな、というところですかね。イベントをどうモデリングするかっていうのは、頭を抱えましたし、モデルって、常に省みるものなので、今でもこれは正しいのかなぁ、どうなんだろうなぁっていうのは、悩み続けているし、大変だったところですかね。

一方でより技術的な面でいうと実はそんなに大変ではなかった、 とは思っていて。弊社ではFirestoreを中心的なアプリケーションのデータベースとして使ってるんですけど、それがEventarcっていうイベント駆動のためのサービスとうまく繋げることができたので、イベント駆動をシステム的に実現するっていうのは、思ったより大変ではなかったとこでした。

Stailerでは主にDB(Firestore)上の特定のデータの書き込みや更新をトリガーとし、Cloud Runを経由して期待されるコンポーネントの処理が実行される。

Eventarc: Google Cloudの内外のイベントを統合して処理できるサービス。Cloud StorageやCloud Pub/Sub、Firebaseなどからのイベントをトリガーにして、Cloud RunやCloud Functionsで処理を実行することができる。

意思を持って言葉を定義すること

── モデリングをちゃんとやろうとしたとき、使ってる言葉がブレてるとか、そもそも言葉の定義が決まってないみたいで苦労することがあるんじゃないかと思うんすけど、その辺で何か工夫されたこととかってありますか。

鈴木: まず、自分たちで決めないと、その言葉がないってことからスタートだなって思ってまして。

例えば会計とかクレジットカードみたいなドメインだと、その業界の中でいろんな言葉があったりするんですけど、僕らの作ってるピックパックってそもそも紙ベースで仕事がされてた頃からシステム化しましたっていう経緯がありまして。

紙のときにはそもそもやってなかった業務が増えてる。 増えた業務ってまだ名前がなかったり、ふわふわしてたり、名前があるけど、実は何かお届けの領域にとっては正しくない名前をしてるとか。

そういうのが多かったんで、自分たちでこれはこういう言葉を使うんだっていう強い意志を持ってことを決めるっていうことがすごい大事でした。そこで、そのユビキタス言語辞書を作ってみるっていうのはすごいやってよかったというか、これがなかったら厳しかったなっていうとこですよね。

お届けチームのユビキタス言語辞書の一部

── モデリングを見直したときって、既存のデータ構造を変えるみたいな必要があると思うんですけど、実際動くものがある状態でデータ構造とかモデルを変える大変さってありましたか?

鈴木: そうですね。 もちろんデータマイグレーションとかは大変なんですけど、幸いにもお届けチームが受け持つ領域って、一つのデータの生存期間というか、使われる期間が非常に短いんですね。

お客さまが注文を入れてから、手元に行くまでの間で扱われるデータなので、長くても2週間ぐらいで役目を終えるし、後からその生のデータを省みるってことはあんまりないんですよね。

データの生存期間が短いんで、並走期間はその2週間分を見てダブルライトして短い期間の移行期間を経て対応できたので、実はそんな大変じゃなかったんですよね。

イベント駆動アーキテクチャの恩恵

── お届けチームが持ってるコンポーネントと他のチームのコンポーネントが密結合だと、境界上から見えづらくてモデリングしづらいみたいなところとかもあったりするんじゃないかなと思うんすけど、その辺で苦労されたこととか、工夫されたことってありますか。

鈴木: そうですね。 ピックパックの話で言うと、幸い他のチームが運んできたものを最後扱うところなので、比較的自分たちの都合で決めるというか、あんまりお伺いを立てずに進められたかなとは思ってますね。

なので他チームとのインタラクションという観点だと、ここまではあんまり苦労はなかったです。ただ、チームの中で発見したり、作った言葉を他チームに浸透していく大変さを、これから味わっていくんだろうな、というのは予感しています。

── イベント駆動アーキテクチャを導入してみて、実際にどんな恩恵が得られましたか?

鈴木: 保守性の話にも繋がると思うんですけど、イベント駆動で開発をするとイベントを大事にするからモデリングをしっかりする、といったようにモデリングする動機付けになったという部分があります。

また、イベントを保存して扱うというやり方になったので、トラブルシューティングのときに信用できる事実が溜まってくってのはすごい良かったことですね。

以前からも業務の生産性を計測するために、いわゆるGoogleアナリティクスにログを送るみたいなことをしてました。 広い意味だとそれもログではあるんですけど、どうしてもデータが使いにくかったり、実際にシステムに反映されてる状態と、Googleアナリティクスにあるログは乖離し得るものなので、何かデータの不整合があるんですけどみたいなことがあったりしました。

イベント駆動を始めてイベントを保存するようになってから、それを基準に業務の生産性を図ることができるようになりました。システムの都合で変な不整合が起きないっていうのは、明確に前進したかなっていうところですね。

理想のモデリングを志すことに価値がある

── 正しく業務の内容を記録するされるっていう副産物も得ることができたってことですね。ありがとうございます。 最後に何か今、この話を聞いて、イベント駆動を自分をやってみたいなってなった人へのアドバイスとかってありますか。

鈴木: いろいろあるけど、やっぱりイベントを通してモデリングする業務に着目することですね。この業務の中ではどういうことが起きるんだろうっていうのに着目して作っていくことが何よりも大事かなって思ってます。

あとはイベント駆動なので非同期処理が入ってくると、非同期であるが故に技術的に難しいことがたくさん出てくる。 そこは失敗しながら、学んでいくしかないのかなと思ってます。

それから、もし仮にですけど試してみたが、やはりイベント駆動はやめておこう、という結果になっても、イベント駆動を志してモデリングした経験は残ると思うんですよね。イベント駆動アーキテクチャを最終的に採用しなくても、今後の自分のモデリングの糧にはなるはずなので、ぜひやってみるといいんじゃないかなと思います。

モデリングを大事にしてやってみると良い、というのは明確にアドバイスの一つかな、と思います。

── なるほど。イベント駆動が導入されたこと自体より、業務があるべき姿にモデリングされていることこそが一番良い成果、というような。

鈴木: そうですね。志すといいますか…あるべき姿、完璧になるってことはやっぱ難しいし、出来ないかもしれないんですが、でもそれを目指す。 目指して、頭の中に理想がある状態を作る。足元の現状を考える際にそうした理想とのギャップを考えることができると良くて、それをさらにシステムに反映できるとより良いな、と思ってます。

おわりに

10Xではより良い設計、より良いモデリングによってプロダクトや事業を成長させたい、というソフトウェアエンジニアを募集しています。 この記事を読んで、自分も理想のモデリングを志したいと、と感じた方はぜひ下記をご覧ください。 open.talentio.com