AWS Amplify gen2 のobserveQueryによるDynamoDBへのスキャン実行タイミング

AWS Amplify gen2 のobserveQueryによるDynamoDBへのスキャン実行タイミング

Clock Icon2025.04.22

こんにちは、なおにしです。

AWS Amplify gen2 のobserveQueryによるDynamoDBへのアクセス挙動を確認する機会がありましたのでご紹介します。

はじめに

AWS Amplify Gen 2 (以降、Amplify)では、内部的にはAWS AppSyncを利用したリアルタイムイベントのサブスクライブ機能が提供されています。

https://dev.classmethod.jp/articles/amplify-gen2-realtime-subscription/

上記の記事やドキュメントのクイックスタートでも使用されているobserveQueryメソッドがサブスクライブするための主な方法のようですが、サブスクライブに関するドキュメントには以下の記載があります。

observeQuery fetches and paginates through all of your available data in the cloud. While data is syncing from the cloud, snapshots will contain all of the items synced so far and an isSynced status of false. When the sync process is complete, a snapshot will be emitted with all the records in the local store and an isSynced status of true.

(機械翻訳)

observeQueryはクラウド内の利用可能なすべてのデータを取得し、ページ分割します。クラウドからデータを同期している間、スナップショットにはこれまでに同期されたすべてのアイテムが含まれ、isSynced ステータスは false になります。同期プロセスが完了すると、ローカル ストア内のすべてのレコードと、isSynced ステータスが true のスナップショットが生成されます

observeQueryはクラウド内の利用可能なすべてのデータを取得という部分が気になりますが、実際どのような挙動になるのでしょうか。

先に結論

  • observeQueryは初回のデータロード時にDynamoDBに対してScanを実行します
    • 意図しないScan操作は想定外の料金発生につながる可能性があるのでご注意ください
  • 初回のScanによってデータがロードされた後は、作成/更新/削除された対象データだけがWebSocketによって送信されます
    • observeQueryのコールバック関数の引数は作成/更新/削除後の全データになっていますが、毎回Scan操作が実行されているわけではありません
  • 作成/更新/削除された対象データのみをコールバック関数で取得する場合は、observeQueryではなくonCreate/onUpdate/onDeleteメソッドといった各種データ操作に紐づくサブスクライブを使用します

というわけで実際にクイックスタートの内容を基に、動作確認とobserveQueryを使わない実装を試してみます。

やってみた

動作確認

ドキュメントにあるクイックスタートの「5. Implement delete functionality」までを適用したプロジェクトをサンドボックスで動かせている状態という前提で進めます。

上記の状態で、サブスクライブによって取得されているデータがどのような内容になっているか確認するためにコンソールへのログ出力を追加します。

src/App.tsx
import { useEffect, useState } from "react";
import type { Schema } from "../amplify/data/resource";
import { generateClient } from "aws-amplify/data";

const client = generateClient<Schema>();

function App() {
  const [todos, setTodos] = useState<Array<Schema["Todo"]["type"]>>([]);

  useEffect(() => {
    client.models.Todo.observeQuery().subscribe({
      next: (data) => {
        setTodos([...data.items]);
+        console.log(data.items);
      }
    });
  }, []);

  function createTodo() {
    client.models.Todo.create({ content: window.prompt("Todo content") });
  }

  function deleteTodo(id: string) {
    client.models.Todo.delete({ id })
  }

  return (
    <main>
      <h1>My todos</h1>
      <button onClick={createTodo}>+ new</button>
      <ul>
        {todos.map((todo) => (
          <li onClick={() => deleteTodo(todo.id)} key={todo.id}>{todo.content}</li>
        ))}
      </ul>
      <div>
        🥳 App successfully hosted. Try creating a new todo.
        <br />
        <a href="https://docs.amplify.aws/react/start/quickstart/#make-frontend-updates">
          Review next step of this tutorial.
        </a>
      </div>
    </main>
  );
}

export default App;

それではToDoリストを追加してみます。

1つ目追加。

20250422_naonishi_aws-amplify-gen2-observequery-dynamodb-scan-timing_1.png

登録自体はできていて画面上の表示も問題ないのですが、同じデータが2回返ってきています。
本記事の趣旨とずれるので補足事項にするか悩んだのですが、前提とするコードが変わってくるのでこのまま原因についても触れます。
2回データが返されてしまう原因は、React18のStrictModeによるものです。どういった挙動をするのかは以下の記事・ドキュメントをご参照ください。

https://qiita.com/asahina820/items/665c55594cfd55e6f14a

https://ja.react.dev/reference/react/StrictMode

クイックスタートに従ってテンプレートからプロジェクトを作成した場合、以下のようにStrictModeが適用されているはずです。

src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { Amplify } from "aws-amplify";
import outputs from "../amplify_outputs.json";

Amplify.configure(outputs);

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

こちらに関してはuseEffectでクリーンアップ関数が定義されていないことが問題なので追記します。実際、サブスクライブに関する以下のドキュメントではきちんとクリーンアップ関数としてサブスクライブの解除処理が定義されていますので、そのとおりにします。

https://docs.amplify.aws/react/build-a-backend/data/subscribe-data/#set-up-a-real-time-list-query

src/App.tsx 抜粋
  useEffect(() => {
+    const sub = client.models.Todo.observeQuery().subscribe({
      next: (data) => {
        setTodos([...data.items]);
        console.log(data.items);
      }
    });
+    return () => {
+      sub.unsubscribe();
+    }
  }, []);

気を取り直してToDoリストを追加していきます。

20250422_naonishi_aws-amplify-gen2-observequery-dynamodb-scan-timing_2.png

返ってくるデータが想定どおり1つになりました。(補足:初回ロード時の空データ([])についてはStrictModeの挙動として2回返ってくるのでは?とも思ったのですが、サブスクライブによるコールバック関数の実行はStrictModeによるチェックの後に1回しか返ってこないようです)

続いて2つ目を登録します。

20250422_naonishi_aws-amplify-gen2-observequery-dynamodb-scan-timing_3.png

3つ目。

20250422_naonishi_aws-amplify-gen2-observequery-dynamodb-scan-timing_4.png

ここまでで挙動としては見えてきたかと思いますが、リストを追加する度にこれまで登録したデータも一緒に取得されています。

このタイミングで「もしかして毎回DynamoDBにScanを実行している...?」と疑ってしまったのですが、結果的には杞憂でした。
ChromeのデベロッパーツールではWebSocketの通信内容を見ることもできるようなので確認してみました。

4つ目のToDoリストを追加する操作を行ったブラウザとは別のブラウザで確認した結果が以下のとおりです。[ネットワーク]タブで[WS]を選択して、[graphql]の[メッセージ]から表示できます。

20250422_naonishi_aws-amplify-gen2-observequery-dynamodb-scan-timing_5.png

表示されているとおり、ペイロードとしては4つ目のToDoリストデータのみとなっています。
その他に確認したこととして、DynamoDBから直接4つ目のToDoリストデータを削除した上で5つ目を追加してみましたが、4つ目は表示されたままの状態でした(都度Scanが実行されるのであれば手動で削除した4つ目のデータの表示は消えるはずです)。

なお、3つ目を登録した時点で改めてブラウザを更新し、DynamoDBのメトリクスを確認したところ、以下のとおりスキャンのカウントが記録されていました。

20250422_naonishi_aws-amplify-gen2-observequery-dynamodb-scan-timing_11.jpg

observeQueryを使わない実装

今回のクイックスタートにあるToDoリストの場合、機能としては以下のように分割することができます。

  • ブラウザで開いた時(初期データのロード時)に登録されているToDoリストを全件取得して表示する
  • +newボタンによってToDoリストが作成されたらブラウザに表示する
  • 特定のToDoリストをクリックすることで対象が削除されたらブラウザからも表示を消す

observeQueryによるサブスクライブで上記を実装している場合、初期データのロード以降の操作も全データを使用したステートの上書きになっています。シンプルかつ今回のToDoリストという用途では問題ないかと思いますが、作成/更新/削除されたデータだけを対象に操作したいケースもあるかと思います。

そこで、実際にobserveQueryを使わない形で上記機能を実装してみます。

まず、初期データのロードについてはドキュメントにあるlistメソッドに置き換え可能です。こちらはobserveQueryの初回ロードと同じようにDynamoDBに対してScanが実行されます。

src/App.tsx 抜粋
  useEffect(() => {
    const fetchTodoList = async () => {
      const response = await client.models.Todo.list();
      console.log("全件取得", response.data);
      setTodos([...response.data]);
    }

    fetchTodoList();

続いて、ToDoリストの作成/削除時のサブスクライブを実装します。それぞれでサブスクライブするため、クリーンアップ関数でもそれぞれをアンサブスクライブする必要があります。また、今回のToDoリストの機能では更新操作が無いためonUpdateについては使用しません。

src/App.tsx 抜粋
		// AppSync経由でデータが作成された時にコールバック関数が実行される
    const createSub = client.models.Todo.onCreate().subscribe({
      next: (data) => {
        console.log("データ作成", data);
        setTodos((prev) => [...prev, data]);
      }
    });

		// AppSync経由でデータが削除された時にコールバック関数が実行される
    const deleteSub = client.models.Todo.onDelete().subscribe({
      next: (data) => {
        console.log("データ削除", data);
        // data.idと一致するidを持つToDoリストを除外
        setTodos((prev) => prev.filter((todo) => todo.id !== data.id));
      }
    });
    return () => {
      createSub.unsubscribe();
      deleteSub.unsubscribe();
    }
  }, []);

onCreateとonDeleteではそれぞれ作成・削除した対象のデータだけが引数となってコールバック関数が実行されます。このため、useStateフックで前の状態の値を対象にデータの追加・削除を行っています。

ここまでの内容で、App.tsxのコード全体は以下のようになっています。

src/App.tsx
import { useEffect, useState } from "react";
import type { Schema } from "../amplify/data/resource";
import { generateClient } from "aws-amplify/data";

const client = generateClient<Schema>();

function App() {
  const [todos, setTodos] = useState<Array<Schema["Todo"]["type"]>>([]);

  useEffect(() => {
    const fetchTodoList = async () => {
      const response = await client.models.Todo.list();
      console.log("全件取得", response.data);
      setTodos([...response.data]);
    }

    fetchTodoList();

    const createSub = client.models.Todo.onCreate().subscribe({
      next: (data) => {
        console.log("データ作成", data);
        setTodos((prev) => [...prev, data]);
      }
    });

    const deleteSub = client.models.Todo.onDelete().subscribe({
      next: (data) => {
        console.log("データ削除", data);
        setTodos((prev) => prev.filter((todo) => todo.id !== data.id));
      }
    });
    return () => {
      createSub.unsubscribe();
      deleteSub.unsubscribe();
    }
  }, []);

  function createTodo() {
    client.models.Todo.create({ content: window.prompt("Todo content") });
  }

  function deleteTodo(id: string) {
    client.models.Todo.delete({ id })
  }

  return (
    <main>
      <h1>My todos</h1>
      <button onClick={createTodo}>+ new</button>
      <ul>
        {todos.map((todo) => (
          <li onClick={() => deleteTodo(todo.id)} key={todo.id}>{todo.content}</li>
        ))}
      </ul>
      <div>
        🥳 App successfully hosted. Try creating a new todo.
        <br />
        <a href="https://docs.amplify.aws/react/start/quickstart/#make-frontend-updates">
          Review next step of this tutorial.
        </a>
      </div>
    </main>
  );
}

export default App;

改めて上記のコードでToDoリストを追加・削除してみます。

1つ目追加。

20250422_naonishi_aws-amplify-gen2-observequery-dynamodb-scan-timing_6.png

listメソッドを使用しているため、observeQueryによるサブスクライブとは違ってStrictModeによって初回ロード時の全件取得が2回実行されているようです。続いて2つ目を追加します。

20250422_naonishi_aws-amplify-gen2-observequery-dynamodb-scan-timing_7.png

1つ目のデータは出力されず、2つ目のデータだけが対象になっています。念の為3つ目も追加してみます。

20250422_naonishi_aws-amplify-gen2-observequery-dynamodb-scan-timing_8.png

問題なさそうです。最後に3つ目削除してみます。

20250422_naonishi_aws-amplify-gen2-observequery-dynamodb-scan-timing_9.png

作成・削除したデータだけを対象に処理することができました。

また、DynamoDB側で実行されたリクエストについても以下の流れでどうなるか確認してみました。

  • 13:25 ブラウザ表示

  • 13:26 ToDoリストを1つ作成

  • 13:27 ToDoリストを1つ削除

ポイントが小さくて少し見えにくいかと思いますが、結果は以下のとおりです。

20250422_naonishi_aws-amplify-gen2-observequery-dynamodb-scan-timing_10.png

以下のとおりリクエストが実行されていました。

  • 13:25 Scanが2回リクエスト

  • 13:26 PutItemが1回リクエスト

  • 13:27 DeleteItemとGetItemが1回リクエスト

削除自体はidのみ指定して実行されているため、id以外のデータを取得するためにDeleteItemと併せてGetItemもリクエストされているかと思います。この辺りはAppSyncのスキーマや関数を読み解けば裏付け可能なのですが、複雑かつGraphQLについては学習不足のため割愛させていただきます。

まとめ

Amplify gen2で作成したアプリの動作を確認している際、DynamoDBのメトリクスでScanがカウントされていたため、どのタイミングで実行されているのかを洗い出すために調査してみました。

本記事がどなたかのお役に立てれば幸いです。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.