STORES Product Blog

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

複数端末間でのリアルタイム・ステータス同期の機能開発

メリークリスマス!Android エンジニアの naberyo(@error96num)です。 この記事は STORES Advent Calendar 2025 の 24 日目の記事です。

私が現在開発に携わっている STORES モバイルオーダー では、この1年間で多くの機能をリリースしてきました。イートイン対応をはじめとした大規模な機能拡張により、さまざまな飲食店でご利用いただけるサービスになっています。 このアップデートについては次の記事で触れられています。 product.st.inc

機能アップデートの過程で、私たちは技術的課題を一つ一つ解決してきました。今回の記事では、その中の一つである「複数台キッチンディスプレイ間での調理ステータス連携機能」を取り上げます。1週間のスプリントの中で、どのような設計判断を行い実装したのか、その舞台裏をご紹介します。

背景:なぜ複数台の連携が必要になったのか

キッチンディスプレイは飲食店の注文管理アプリです。お客様が注文した商品をスタッフさんが把握し、調理や食事提供のステータスを管理します。

従来のキッチンディスプレイは、小規模飲食店での利用を想定しており、1台での運用を前提としていました。

しかし、サービスの成長に伴い、より大きな店舗での利用を可能にする必要が出てきました。例えば、ドリンクとフードが別々の場所で調理されていたり、キッチンとホールが離れていたりする店舗など、1台のキッチンディスプレイではカバーしきれない多様なオペレーションへの対応が求められるようになったのです。

このような背景から、店内に複数台のキッチンディスプレイを配置し、それらの間で調理ステータスをリアルタイムに連動させる必要性が生まれました。

UXから考える:どんなオペレーションを実現したいのか

複数台の連携が解決する具体的な飲食店のオペレーションを考えてみましょう。

例えば、ファストフード店で以下のような分担があるとします:

  • 担当者1(ドリンク担当): 各オーダーに含まれるドリンクを調理
  • 担当者2(フード担当): 各オーダーに含まれるフードを調理
  • 担当者3(配膳担当): 完成した商品をお客様に提供

従来の1台運用では、ドリンクやフードの調理担当者が商品を「調理完了」としてチェックしても、配膳担当者が同じ端末の画面を見に行かなければ状況を確認できませんでした。これでは設置場所やオペレーションに大きな制約を生んでしまいます。

一方で、複数台の調理ステータスが連携されることで、各担当者が別々の端末を操作することができるようになります。ドリンク担当がドリンクを、フード担当がフードを「調理完了」とすると、配膳担当のディスプレイにも反映されます。これにより、それぞれの担当者が自分の持ち場でスムーズに調理状況を把握することができるようになります。

設計判断

複数台での連携を実現するにあたり、最も議論したのは「通信の正確性」と「操作の即時性」のトレードオフ、そして「複数台で同時に操作された際の整合性」でした。

現場の作業を止めない「超・楽観的更新」の採用

飲食店の忙しいピークタイムでは、0.1秒の操作ブロックも致命的なストレスに繋がります。

仮に、通信の正確性を最優先して「APIのレスポンスを待ってからUIを更新する」という堅実な設計を採用した場合、ネットワークの遅延や一時的な瞬断によって調理完了の操作がワンテンポ遅れることになります。これは、飲食店のスムーズなオペレーションを支援するというキッチンディスプレイの本来の目的に反します。

そこで私たちは、通信の完了を待たずにUIを更新する楽観的更新(Optimistic Update)をベースに、さらに踏み込んだ設計判断を行いました。

  1. 即時反映を最優先: ユーザーのタップと同時にローカルの状態を更新し、UIを即座に変更します。API通信は常にバックグラウンドで非同期に行われます。
  2. エラー時も「巻き戻さない」: 通常の楽観的更新では通信失敗時にUIを元の状態へ戻す(ロールバック)処理を行いますが、本機能ではあえてロールバックを行いません。
  3. 「いつかは整合する」再試行モデル: 通信が失敗してもローカルの状態を正解とし続け、即座にエラーを返したりロールバックしたりはしません。失敗したMutationはオンメモリに保持され、次に別のアイテムがチェックされるなどの操作が行われた際に、前回の失敗分も含めて再送(リトライ)される仕組みにしています。これにより、一時的な瞬断があっても、ユーザーが操作を続けるうちに自然とリモートとの整合性が取れるようになっています。

この「エラーを即座にフィードバックせず、ローカルの操作を信じ続ける」という判断により、たとえ一時的にネットワークが不安定になっても、スタッフの手を止めることなく調理ステータスを管理し続けられるUXを実現しました。

タイムスタンプベースの後勝ちルール

「超・楽観的更新」によって各端末が自由にステータスを更新できるようになると、「複数の端末から、ほぼ同時に同じアイテムの調理ステータスが更新された場合」の整合性をどう保つかが課題となります。

前述の通り、基本的には担当ごとに操作するアイテムは分かれていますが、実際の現場では以下のようなケースが起こり得ます。

  • 重複操作: メインの調理担当がチェックし忘れたものを、配膳担当が手元のキッチンディスプレイから代わりにチェックする
  • 矛盾する操作: 一度チェックしたものを「やっぱりまだだった」と、別の場所で即座にアンチェック(戻す操作)をする

このような複数台からの矛盾し得る操作や、ネットワーク遅延によるリクエスト順序の入れ替わりが発生した際、どの操作を信じるべきでしょうか。

そこで採用したのが、「タイムスタンプが最新の状態を常に正とする」という後勝ちルールです。

操作ごとに付与されたタイムスタンプをキーとし、常に最新の時刻を持つデータが最終的なステータスとして扱われるように制御しました。これにより、たとえ古いステータスが遅れて届いたとしても、最新の状態が過去の状態に上書きされるのを防ぎ、すべての端末が「一番最後に行われた意思決定」という正しい状態に収束するようにしました。

ローカルDBをSSoTとしたデータフロー

この「現場を止めないUX」を支えるのが、ローカルDBを SSoT(Single Source of Truth:信頼できる唯一の情報源) とする設計です。

UIは常に目の前のローカルDBだけを参照し、リモート(サーバー)との同期はバックグラウンドで整合性を保つという「責務の分離」を徹底しました。この思想を実現するためのデータフローは以下の通りです。

更新時のデータフロー

ユーザーがアイテムをチェック/アンチェックしたときの動作:

graph TB
    subgraph "キッチンディスプレイ"
        UI1[UI Layer]
        LocalDB1[(Local DB)]
    end

    subgraph "リモート"
        API1[GraphQL API]
        RemoteDB1[(Remote DB)]
    end

    UI1 -->|1. ユーザーがタップ| LocalDB1
    LocalDB1 -->|2. 即座にUI更新| UI1
    LocalDB1 -.->|3. 非同期でMutation| API1
    API1 --> |4. 保存|RemoteDB1

    style LocalDB1 fill:#e1f5ff
    style UI1 fill:#fff4e1
  1. ユーザーがUIでアイテムをチェック/アンチェック
  2. ローカルDBに即座に書き込み→UIはローカルDBから即座に反映(ユーザー体験を損なわない)
  3. 非同期でリモートAPIにMutationを送信
  4. リモートDBに保存

取得時のデータフロー

サーバーからオーダーを取得したときの動作:

graph TB
    subgraph "リモート"
        RemoteDB2[(Remote DB)]
        API2[GraphQL API]
    end

    subgraph "キッチンディスプレイ"
        LocalDB2[(Local DB)]
        UI2[UI Layer]
    end

    RemoteDB2 --> API2
    API2 -->|1. オーダー取得<br/>(調理ステータス含む)| LocalDB2
    LocalDB2 -->|2. 自動反映| UI2

    style LocalDB2 fill:#e1f5ff
    style UI2 fill:#fff4e1
  1. サーバーからオーダーを取得し、レスポンスに含まれる調理ステータス情報をローカルDBに同期
  2. ローカルDBの変更がUIに自動反映

この設計により、ユーザー操作は常に即座にローカルDBに反映され、サーバーへの送信はバックグラウンドで行われるため、ネットワーク遅延があってもUXを損ないません。

実装の詳細

テーブル設計の変更とタイムスタンプ管理

既存のテーブル構造を拡張し、調理ステータスの履歴をタイムスタンプ付きで保存できるようにしました。変更前はチェックされたアイテムのIDのみを保持していましたが、変更後は各アイテムの調理ステータス(TRUE/FALSE)と変更日時を履歴として記録します。

CREATE TABLE itemStatus (
  itemId TEXT NOT NULL,
  changedAt INTEGER NOT NULL,  -- UNIX時間(ミリ秒)
  isCooked INTEGER NOT NULL,    -- Boolean (0/1)
  PRIMARY KEY (itemId, changedAt)
);

CREATE INDEX idx_item_changed
ON itemStatus(itemId, changedAt DESC);

重要なポイントは、複合主キーにchangedAt(タイムスタンプ)を含めたことです。これにより、同じアイテムに対する複数の状態変更を履歴として保存でき、タイムスタンプが最新のレコードを取得するクエリで常に最新ステータスを取得できます。

ローカルとリモートの同期処理

GraphQL Mutationを追加してリモートサーバーとの双方向同期を実装しました:

// ユーザー操作時:ローカルDB更新 + リモートMutation
suspend fun updateItemStatuses(
    itemIds: List<String>,
    isCooked: Boolean,
    changedAt: Instant,
) {
    // 1. ローカルDBに即座に保存
    localDataSource.updateItemStatuses(itemIds, isCooked, changedAt)

    // 2. 非同期でリモートに送信(失敗してもUI更新は完了している)
    try {
        remoteDataSource.updateItemStatuses(itemIds, isCooked, changedAt)
    } catch (exception: Exception) {
        // エラーログの記録だけして、UIにはフィードバックしない
        recordException(exception)
    }
}

サーバーからオーダーを取得する際は、レスポンスに含まれる調理ステータス情報を抽出してローカルDBに同期します。これにより、別のキッチンディスプレイでの操作が反映されます。

できたもの

最終的に、次のような機能が完成しました。

一方(画像左側)のキッチンディスプレイを操作してアイテムをタップすると、もう一方(画像右側)のキッチンディスプレイへリアルタイムに状態が同期されるようになりました🎉

さいごに

モバイルオーダーチームの中で日々行われている開発の一例をご紹介しました。

今回の機能一つをとっても、「通信の正確性」と「操作の即時性」のどちらを取るかといった、トレードオフがいくつもありました。私たちはそうした課題に対し、日々活発に議論を交わし、その時々のベストな判断を下しながら、スピード感のある開発をしています。

こうした技術的な深掘りやユーザーへの価値提供を、一緒に楽しめる仲間を探しています。

この記事を読んで STORES に少しでも興味をもっていただけた方がいらっしゃいましたら、転職意欲の有無にかかわらず、ぜひカジュアルにお話しができると嬉しいです!

jobs.st.inc

アドベントカレンダーも残り1日、明日の記事もお楽しみに!