STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

複数の検索条件をリアルタイムに判定するスマートリストの設計

こんにちは、STORES でエンジニアをしているmochizukiです。

STORES 予約 では、5月に「スマートリスト」という新機能をリリースしました。 このブログでは、このスマートリストを実現するために採用した設計と、その背景についてお話しします。

スマートリストとは

STORES 予約 にはもともと、顧客の一覧を表示し、条件を指定して絞り込みを行うことができる顧客リスト画面がありました。

この条件に名前をつけて保存できるようにしたのがスマートリストです。

顧客リストでは、スマートリストを設定することでいつでもすぐにその条件に合致する顧客をリストアップできるようになりました。

さらに、各顧客が属するスマートリストが一目で分かるように、その顧客が該当するスマートリストをラベルとして表示する項目も追加されました。

この画像のように、顧客リストには各顧客が該当するスマートリストがラベルとして表示されます。これにより、顧客がどの条件に該当しているかを一目で確認できます。

詳しくは以下の記事をご覧ください。

www.st.inc

スマートリストの要件

背景と解決したい課題

STORES 予約 のユーザーである店舗オーナー様の規模が大きくなるにつれ、顧客数やスタッフ数も多くなってきます。

そこで出てくる問題として、対応が必要な顧客をリストアップするのが大変で、顧客との適切なコミュニケーションが徹底できないことがありました。 これにより、再来店率や回数券の消化率、継続購入率に課題が生じていました。

このような状況を解決する手段として、現場のスタッフがコミュニケーションを取るべき顧客を簡単に把握できるようにするために、スマートリストの導入を検討しました。

要件

絞り込んだ条件を保存し、素早くリストを確認できる機能が求められました。 また、新しく条件に合致した顧客を検知し、その顧客に対してアクションを取れるようにすることも重要です。

具体的には以下のような要件が挙げられました。

  • 絞り込んだ条件に名前をつけて保存、編集、削除ができる(保存した検索条件を「スマートリスト」と呼ぶ)
  • 各スマートリストに属する顧客一覧の取得と表示ができる
    • 保存したスマートリストを選択すると、その条件に該当する顧客が検索されて顧客リストに表示される
  • 各顧客が属するスマートリスト名の取得と表示ができる
    • 顧客リスト上や顧客詳細画面で、顧客が属するスマートリスト名を確認できるようにするため、設定した色を背景にスマートリスト名が書かれているラベルが表示される
    • 複数のスマートリストに属する場合は、すべてのスマートリスト名が表示される

設計の検討

スマートリストの条件に合致する顧客の検索自体は、これまでの顧客リストでの検索と同様であるため新しい課題はありません。 しかし今回スマートリストで実現したい要件の中には、顧客リストに表示した各顧客が属するスマートリストをラベルとして表示するというものがあり、これをどう実現するのかを考える必要があります。

ラベルを表示するためには、当然その顧客が各スマートリストの条件に合致しているかの判定が必要です。 計算コストがどの程度増大するのか、その上でどのような最適化を行うべきか、といったところが設計上の課題となりました。

この設計のためのポイントをいくつか見ていきます。

どうやって判定するか

考えられる判定方法として、顧客検索の際に使っているSearchFormというクラスを使ってDB検索で判定する方法と、それを使わずにRuby上で判定する方法が挙げられました。

SearchFormを使ったDB検索で判定する方法

SearchFormとは、検索条件を基に顧客情報をフィルタリングするためのフォームオブジェクトです。 これにより、検索条件の処理を一箇所に集約し、コードの再利用性と保守性を向上させています。

顧客リストでの検索では、以下のようにSearchFormを利用して検索を行っています。

def index
  form = SearchForm.new(search_params:, merchant:)
  target_customers = form.execute

  # ...
end

そこで、顧客が対象のスマートリストの条件に合致しているかの判定でもこのSearchFormを利用する方法が考えられます。

SearchFormにスマートリストの条件を渡し、さらに対象の顧客IDで絞り込むことで、結果が空になるか対象顧客が含まれるかでの判定が行えます。

SearchForm.new(search_params:, merchant:).execute.where(id: customer_id)

しかしこの方法をそのまま使うと、各顧客に対し各スマートリストに合致するかを判定するたびにDB検索をかけることになります。

「顧客数 * スマートリスト数」のDBへの問い合わせをかけるのは現実的ではないため、SearchFormを使う場合は問い合わせ数を減らす工夫が必要そうです。

SearchFormを使わずRuby上で判定する方法

判定方法としてもう一つ考えられるのが、対象の顧客が特定のスマートリストに属するかどうかをRuby上で判定する方法です。

顧客リストに表示する対象顧客を取得する段階で、Customerテーブル以外で判定に必要なテーブルもincludeします(例:予約情報のテーブルなど)。

この方法では、判定のために都度DBへの問い合わせは行わないためパフォーマンスは悪くなさそうです。 しかし、それぞれの条件を判定するロジックを新たに実装する必要があり、判定ロジックがSearchFormと同様であることを保証しながら二重管理する必要が出てきます。

どのタイミングで判定するか

判定を行うタイミングも検討事項でした。

顧客リストを表示する時、すなわちラベル表示のタイミングでリアルタイムに判定するか、あらかじめ判定しておいて結果を保持しておく方法も考えられます。

あらかじめ判定して結果を保持する場合、スマートリストと顧客の紐付けを保存する中間テーブルを用意し、定期的にバッチ処理で更新する方法が考えられます。 この方法では、スマートリストごとに該当する顧客を事前に計算し、結果をDBに保存しておきます。 ラベル表示のタイミングでは、各顧客に中間テーブルを介して関連づくスマートリストを取得して表示します。

この2つの方法は、それぞれ次のようなpros/consが挙げられます。

リアルタイム判定

  • pros
    • データの鮮度が高い
    • あらかじめ重たい処理をする必要がない
  • cons
    • 表示時のパフォーマンスが気になる

あらかじめ判定して結果を保持

  • pros
    • 事前に計算された結果を利用するため表示時のパフォーマンスは良さそう
  • cons
    • データの鮮度が落ちる
    • バッチ処理時の負荷が高そう

あらかじめ判定して結果を保持する方法だと、事前に計算された結果を利用するため表示時のパフォーマンスは良さそうですが、今度はバッチ処理時の負荷がどのくらいかかるのかが気になります。

バッチ更新は頻繁に実行すればデータの鮮度は保てますが、その分DBサーバーへの負荷が高くなります。 全テナントの全スマートリストの条件で顧客を検索し、紐付け直す処理が非常に重くなることが予想されました。

また、リアルタイム性が損なわれることもデメリットとなってきます。

スマートリストは顧客に対するアクションを取るために使われるため、データの鮮度が重要です。 例えば、「次回予約なし」の顧客をリストアップするスマートリストでは、バッチ処理の間隔が長いと顧客が予約を取った場合でも、リストに反映されるまでに時間がかかります。

ここまでの検討まとめ

ここまでの検討を踏まえ、特にポイントとなったのが以下の2点でした。

  • 機能としての価値を考えるとデータの鮮度を保つリアルタイム判定が望ましい
  • 実装工数や判定ロジックの二重管理のコストを考え、SearchFormを使って判定したい

そこで、SearchFormを使ってリアルタイムに判定する方法が現実的に可能か、具体的な実装を考えていきました。

具体的な実装方法

検索条件の保存方法

先にDBの設計を見ていきます。 検索条件をどう保存するかについて、大きく分けると以下の2つの選択肢がありました。

  • 予約回数、次回予約日、所持している回数券、回数券の残回数など、検索条件ごとに正規化して保存する
  • すべての検索条件を1つにまとめたJSON形式で保存する

顧客リストは今後も検索条件が増えることが想定されており、条件ごとにカラムを設けるパターンでは、その都度カラムの追加対応が必要になります。

また、保存された検索条件は直接クエリでjoinして使用するのではなく、SearchForm内で検索条件にあったクエリを改めて構築することで検索を行うため、RDB上で正規化されていることの利点が薄いです。

そのため、このSearchFormにわたる入力の検索条件を、そのままJSON型として保持することにしました。

create_table :smart_lists do |t|
  t.string  :name, null: false
  t.string :color, null: false
  t.integer :merchant_id, null: false
  t.json :params, null: false
  
  t.timestamps
end

SearchFormを使ったリアルタイム判定をどう実現するか

リアルタイム判定を採用するには、顧客一覧を表示する際のパフォーマンスの懸念を解消する必要があります。

先述の通り、顧客があるスマートリストの条件に合致しているのかをSearchFormを利用して判定するには、SearchFormにスマートリストの条件を渡し、さらに対象の顧客IDで絞り込む方法があります。

SearchForm.new(search_params:, merchant:).execute.where(id: customer_id)

しかしこの方法をそのまま使うと、「顧客数 * スマートリスト数」のDBへの問い合わせが発生してしまうことが問題でした。

そこでDBへ問い合わせる回数を極力減らすための方法を考えました。

スマートリスト数だけのDB検索で済むよう最適化

1テナントあたり保存できるスマートリストの上限数は、ユースケースやシステム負荷の観点を考慮して最大20件までという仕様にしました。

顧客リストに1度に表示される顧客は最大で50件であるため、その50件の顧客に検索対象を絞った上で、最大20件のスマートリストでの検索を行うことで、ラベル表示のために必要な情報は揃えることができます。

簡単にループ処理で実装するだけだと、各スマートリストごとに属する顧客の配列を持つ形で情報をまとめられます。

result = {}
merchant.smart_lists.each do |list|
  target_customers = SearchForm.new(search_params: list.search_params, merchant:).execute.where(id: 最大50件の顧客ID)
  result[list.name] = target_customers
end
{
  スマートリスト1: [顧客1, 顧客2, 顧客3],
  スマートリスト2: [顧客2],
  スマートリスト3: [顧客3]
}

これを顧客リストに表示するため、各顧客ごとに属するスマートリストの配列を持つ以下のような形に変換したいです。

{
  顧客1: [スマートリスト1],
  顧客2: [スマートリスト1, スマートリスト2],
  顧客3: [スマートリスト1, スマートリスト3]
}

少し工夫をして、以下のような実装でこれを実現しました。

# 表示対象の顧客(最大50件)を取得
target_customers_query = SearchForm.new(search_params:, target_merchant:).execute
target_customers =
  target_customers_query
    .page(params[:page] || 1)
    .per(50)
    .preload(
      :reservation,
      :subscription,
      # etc...
    )
customer_ids = target_customers.pluck(:id)

# 顧客ごとのスマートリストを保持するための入れ物を用意
customer_map = customer_ids.index_with { |_id| [] }

# テナントが設定したスマートリストごとに検索をかけ、表示対象の顧客(最大50件)が属するスマートリストをcustomer_mapに格納
merchant.smart_lists.each do |smart_list|
  matched_customer_ids =
    SearchForm.new(search_params: smart_list.params, target_merchant: merchant)
      .execute
      .where(id: customer_ids)
      .pluck(:id)

  matched_customer_ids.each do |customer_id|
    customer_map[customer_id] << { id: smart_list.id, name: smart_list.name }
  end
end

最終的には、例えば以下のようなcustomer_mapが得られます。

customer_map = {
  1: [
    { id: 10, name: "初回予約の顧客" },
    { id: 11, name: "要フォロー顧客" },
  ],
  5: [
    { id: 10, name: "初回予約の顧客" }
  ]
}

このように、検索範囲を50件まで絞り、検索回数を最大20回までに抑えることができます。 これであれば、SearchFormを使う方法でもパフォーマンスは許容範囲で収まると判断できました。

今回は見送った対応

ここまで紹介した設計によって、パフォーマンスは問題ないレベルになりそうです。 そのためファーストリリースの段階では採用しませんでしたが、さらにパフォーマンスを改善するための案として出ていたものを少し紹介します。

APIの分離

現状ラベル表示のための情報は、顧客検索の結果を返すAPIのレスポンスに組み込む形でフロントへ返しています。 これによって、今までこのAPIでは顧客検索でSearchFormを1回実行していただけなのに対し、ラベル表示の情報を含めるためにさらに最大で20回(スマートリストの保存最大数)SearchFormを実行することになります。

この計算時間が許容できない長さになってきた場合は、ラベル表示用の情報を返すためのAPIを作成し、顧客検索の結果を返すAPIとは分離する方法も考えられます。

分離することによって、顧客検索の結果は先に表示しながら、ラベル表示用のAPIのレスポンスを受け取り次第ラベルも表示することで、顧客一覧の表示自体が遅いということは避けられるようになります。

キャッシュの導入

現在のところ、キャッシュテーブルの作成は行っていませんが、将来的にパフォーマンス問題が発生した場合には、特定のデータに対するキャッシュの導入を検討する余地があります。

例えば、顧客の次回予約日を検索する場合、以下のようなクエリが実行されます。

Customer
  .joins(:reservations)
  .where("reservations.status = ?", "未実施")
  .group("customers.id")
  .having("MIN(reservations.予約日時) >= ?", Date.today)

このクエリは、各顧客の次回予約日をリアルタイムで計算するため、顧客数や予約数が増えると負荷が高くなります。

これを改善するために、次回予約日をキャッシュするテーブルを用意する方法を検討できます。 例えば、customer_next_reservationsというテーブルを作成し、定期的にバッチ処理で更新するとします。 このテーブルには、各顧客の次回予約日が保存されます。

キャッシュテーブルを使用すると、クエリは次のように簡略化されます。

Customer
  .joins("INNER JOIN customer_next_reservations ON customer_next_reservations.customer_id = customers.id")
  .where("customer_next_reservations.next_reservation_date >= ?", Date.today)

このクエリは、次回予約日をキャッシュテーブルから直接取得するため、リアルタイムで計算する必要がなくなり、パフォーマンスが大幅に向上します。

現時点ではキャッシュテーブルの導入は行っていませんが、将来的に必要に応じて検討する予定です。

実装まとめ

スマートリスト機能の設計において、ポイントとなったのはリアルタイム性とパフォーマンスの両立でした。 リアルタイム検索を採用したことにより、常に最新のデータを基に顧客情報を取得できる方法になりましたが、その際にラベル表示のための判定を行うにはパフォーマンスの懸念がありました。

一度に表示される顧客数は最大50件であるため、ラベル表示のためのSearchFormでの検索時には、スキャンする対象をこの表示される顧客だけに絞り、各スマートリストごとに1回の検索で必要な情報を揃え、APIのレスポンスに合った形に整形することで最適化を行いました。

最後に

これからも、ユーザー体験を向上させるための新しい機能を提供していきます。 スマートリストの利用によって、顧客とのコミュニケーションがより効果的になり、業務の効率化に貢献できることを期待しています。

今後も STORES 予約 をよろしくお願いいたします。